Sheets
The modal registry for side sheets: await openSheet(key, props) returns a typed result, and sheets are code-split and stack. Sheets also minimize to a dock, keep drafts, and stay open while you use a popover, menu or modal on top of them.
@dentil/sheets—'use client';
import { useRef, useState } from 'react';
import { useSheet } from '@/components/sheets';
import { Button } from '@/components/ui/button';
export function SheetsOverviewDemo() {
const { openSheet } = useSheet();
const [result, setResult] = useState('—');
const pending = useRef<Promise<unknown> | null>(null);
const edit = async () => {
const request = openSheet(
'edit-details',
{
title: 'Edit customer',
description: 'Click outside to minimize this sheet to the dock. Your edits are kept.',
defaultValues: { name: 'Acme Ltd' },
actions: (
<Button
type="button"
variant="outline"
// `multiple` stacks a second sheet with the same key instead of restoring this one.
onClick={() => openSheet('edit-details', { title: 'Add contact' }, { multiple: true })}>
Add contact
</Button>
),
},
{ size: 'md', minimizable: true, title: 'Acme Ltd' }
);
// Clicking again while the sheet is minimized restores it and returns the same promise.
if (pending.current === request) return;
pending.current = request;
const details = await request;
pending.current = null;
setResult(details ? JSON.stringify(details) : 'dismissed');
};
return (
<div className="flex flex-wrap items-center gap-4">
<Button onClick={edit}>Edit customer</Button>
<span className="text-muted-foreground text-sm">
Result: <code className="text-foreground">{result}</code>
</span>
</div>
);
}Installation
pnpm dlx shadcn@latest add @dentil/sheetsnpx shadcn@latest add @dentil/sheetsyarn dlx shadcn@latest add @dentil/sheetsbunx --bun shadcn@latest add @dentil/sheetsFiles land in components/sheets/, including registry.tsx, which you edit to add sheets. Uses the stock shadcn sheet, button and input. Requires React 19. Without the @dentil registry in components.json, install by URL: https://dentil.dev/r/sheets.json.
Setup
Wrap your app once in SheetProvider. It renders the sheets and the minimized dock itself.
// app/layout.tsx (or your root component)
import { ModalProvider } from '@/components/modals';
import { SheetProvider } from '@/components/sheets';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Using @dentil/modals too? Put SheetProvider inside it so sheets can open modals. */}
<ModalProvider>
<SheetProvider
onClose={(key, { value, reason, durationMs, minimized }) =>
analytics.track('sheet_closed', { key, completed: value !== undefined, reason, durationMs, minimized })
}>
{children}
</SheetProvider>
</ModalProvider>
</body>
</html>
);
}Usage
An edit-details sheet ships with the registry as a starting point.
'use client';
import { useSheet } from '@/components/sheets';
export function EditCustomerButton({ customer }: { customer: Customer }) {
const { openSheet } = useSheet();
const onClick = async () => {
// `details` is `{ name: string; notes: string } | undefined`.
const details = await openSheet(
'edit-details',
{ title: 'Edit customer', defaultValues: { name: customer.name } },
{ size: 'md' }
);
if (details) await updateCustomer(customer.id, details);
};
return <Button onClick={onClick}>Edit</Button>;
}Adding a sheet
Write the content
Rendered inside SheetContent. Type its props with SheetContentProps<TReturn>; render a SheetTitle for screen readers.
// components/sheets/contents/create-price.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import type { SheetContentProps } from '../types'; import { useSheetDraft } from '../use-sheet-draft'; // SheetContentProps<TReturn> adds `sheetId` and `close(value?: TReturn)`. type CreatePriceSheetProps = SheetContentProps<Price> & { productId: string }; export default function CreatePriceSheet({ sheetId, close, productId }: CreatePriceSheetProps) { const [amount, setAmount] = useSheetDraft(sheetId, ''); const [error, setError] = useState<string | null>(null); const submit = async (event: React.FormEvent) => { event.preventDefault(); try { close(await createPrice({ productId, amount })); } catch (err) { setError((err as Error).message); } }; return ( <form className="flex flex-1 flex-col" onSubmit={submit}> <SheetHeader> <SheetTitle>New price</SheetTitle> </SheetHeader> {/* …fields… */} <SheetFooter className="flex-row justify-end"> <Button type="button" variant="outline" onClick={() => close()}> Cancel </Button> <Button type="submit">Create</Button> </SheetFooter> </form> ); }Register it
Keys, props and return types are inferred from the loader.
// components/sheets/registry.tsx const sheetLoaders = { 'edit-details': () => import('./contents/edit-details'), 'prices.create': () => import('./contents/create-price'), // ← the only line to add } as const;Open it
const price = await openSheet('prices.create', { productId }, { minimizable: true, title: 'New price' }); // ^? Price | undefined
Examples
Edit and return a value
Save resolves with the form values; Escape, an outside click or Cancel resolves undefined.
—'use client';
import { useState } from 'react';
import { useSheet } from '@/components/sheets';
import { Button } from '@/components/ui/button';
export function SheetsBasicDemo() {
const { openSheet } = useSheet();
const [result, setResult] = useState('—');
const edit = async () => {
const details = await openSheet('edit-details', {
title: 'Edit customer',
description: 'Changes are saved when you press Save.',
defaultValues: { name: 'Acme Ltd', notes: '' },
});
setResult(details ? JSON.stringify(details) : 'dismissed');
};
return (
<div className="flex flex-wrap items-center gap-4">
<Button onClick={edit}>Edit customer</Button>
<span className="text-muted-foreground text-sm">
Result: <code className="text-foreground">{result}</code>
</span>
</div>
);
}Inset stacking
The default. Every sheet is the same width. Open another from the button inside, up to three deep; the ones underneath shrink back, floating with a gap above and below and rounded corners. Saving opens a confirm modal over the sheets still open, and clicking the modal doesn't close them.
'use client';
import { useModal } from '@/components/modals';
import { useSheet } from '@/components/sheets';
import { Button } from '@/components/ui/button';
const MAX_DEPTH = 3;
const titles = ['Edit invoice', 'Edit line item', 'Edit tax'];
export function SheetsStackingDemo() {
const { openSheet } = useSheet();
const { openModal } = useModal();
const openStacked = (depth: number) => {
const result = openSheet(
'edit-details',
{
title: titles[depth],
defaultValues: depth === 0 ? { name: 'INV-0042' } : undefined,
actions:
depth < MAX_DEPTH - 1 ? (
<Button type="button" variant="outline" onClick={() => openStacked(depth + 1)}>
Open another sheet
</Button>
) : undefined,
},
// `multiple` lets the same sheet key stack instead of restoring the one already open. Every sheet uses `lg`.
{ size: 'lg', multiple: true }
);
void result.then(async (details) => {
if (details) await openModal('confirm', { title: `Save “${details.name}”?` });
});
};
return <Button onClick={() => openStacked(0)}>Open stacked sheets</Button>;
}Flush stacking
With stacking: 'flush', every sheet is the same width. Open another from the button inside, up to three deep. The sheets underneath stay full height with square corners and only shift left.
'use client';
import { useSheet } from '@/components/sheets';
import { Button } from '@/components/ui/button';
const MAX_DEPTH = 3;
const titles = ['Edit invoice', 'Edit line item', 'Edit tax'];
export function SheetsStackingFlushDemo() {
const { openSheet } = useSheet();
const openStacked = (depth: number) => {
void openSheet(
'edit-details',
{
title: titles[depth],
defaultValues: depth === 0 ? { name: 'INV-0042' } : undefined,
actions:
depth < MAX_DEPTH - 1 ? (
<Button type="button" variant="outline" onClick={() => openStacked(depth + 1)}>
Open another sheet
</Button>
) : undefined,
},
{ size: 'lg', multiple: true, stacking: 'flush' }
);
};
return <Button onClick={() => openStacked(0)}>Open flush sheets</Button>;
}Minimize and retained drafts
Type something, then click outside. The first sheet minimizes to the dock; the second closes, and reopening it brings your draft back.
'use client';
import { useSheet } from '@/components/sheets';
import { Button } from '@/components/ui/button';
export function SheetsMinimizeDemo() {
const { openSheet } = useSheet();
return (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() =>
openSheet('edit-details', { title: 'Draft a note' }, { minimizable: true, title: 'Note draft' })
}>
Minimizable sheet
</Button>
<Button
variant="outline"
onClick={() => openSheet('edit-details', { title: 'New customer' }, { retainDraft: true })}>
Sheet that keeps its draft
</Button>
</div>
);
}Drafts
Minimized sheets unmount, so state that should survive minimizing (or, with retainDraft, closing and reopening) goes through useSheetDraft instead of useState. Drafts live in memory for the lifetime of the provider and are cleared when the sheet closes with a value or the close button.
// react-hook-form: seed from the draft and write every change back.
const [draft, setDraft] = useSheetDraft<FormValues>(sheetId, defaultValues);
const form = useForm<FormValues>({ defaultValues: draft });
useEffect(() => {
const subscription = form.watch((values) => setDraft(values as FormValues));
return () => subscription.unsubscribe();
}, [form, setDraft]);API reference
useSheet()
| Prop | Type | Default | Description |
|---|---|---|---|
| openSheet | (key, props?, options?) => Promise<Return | undefined> | — | Opens a sheet on top of the stack. Resolves with the value passed to close, or undefined when dismissed. If a sheet with the same key is already open, it's restored and the same promise is returned (unless multiple is set). |
| closeSheet | (id, value?, reason?) => void | — | Closes a sheet by id. Contents usually call their own close prop instead. |
| minimizeSheet | (id: string) => void | — | Hides a sheet in the dock, keeping its draft. |
| restoreSheet | (id: string) => void | — | Brings a minimized sheet back on top. |
| stack | SheetDescriptor[] | — | Open, minimized and closing sheets, bottom to top. |
openSheet options
| Prop | Type | Default | Description |
|---|---|---|---|
| size | 'default' | 'md' | 'lg' | 'xl' | 'full' | 'default' | Max width from the sm breakpoint: 24rem (stock sheet), 36rem, 42rem, 56rem or full. |
| stacking | 'inset' | 'flush' | 'inset' | How the sheet recedes when another opens on top. inset scales it down so it floats with a gap above and below; flush keeps it full height and only shifts it left. |
| rounded | boolean | stacking === 'inset' | Round the sheet’s exposed left corners. |
| title | string | — | Label in the minimized dock. Defaults to the key in sentence case. |
| dismissible | boolean | true | False ignores outside clicks and Escape. The close button and close() still work. |
| minimizable | boolean | false | Clicking outside minimizes the sheet to the dock instead of closing it. |
| multiple | boolean | false | Allow more than one sheet with this key at a time. Otherwise opening an open key restores that sheet and returns its pending result. |
| retainDraft | boolean | false | Keep the useSheetDraft state when dismissed with Escape or an outside click, and restore it the next time this key opens. |
SheetProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| onOpen | (key: SheetKey) => void | — | Called when any sheet opens. |
| onClose | (key, { value, reason, durationMs, minimized }) => void | — | Called when any sheet closes. reason is 'button', 'escape' or 'backdrop'. |
SheetContentProps<TReturn>
Injected into every sheet content.
| Prop | Type | Default | Description |
|---|---|---|---|
| sheetId | string | — | This sheet’s id. Pass it to useSheetDraft. |
| close | (value?: TReturn) => void | — | Closes this sheet and resolves openSheet. Stable for the life of the sheet. |
useSheetDraft(sheetId, initial)
| Prop | Type | Default | Description |
|---|---|---|---|
| sheetId | string | — | The sheetId prop. |
| initial | T | (() => T) | — | Used when there is no stored draft. |
| returns | [value, setValue, clear] | — | Like useState. clear() drops the stored draft without changing the current value. |