dentil

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
On this page
Result: —

Installation

pnpm dlx shadcn@latest add @dentil/sheets
npx shadcn@latest add @dentil/sheets
yarn dlx shadcn@latest add @dentil/sheets
bunx --bun shadcn@latest add @dentil/sheets

Files 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

  1. 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>
      );
    }
  2. 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;
  3. 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.

Result: —

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.

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.

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.

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()

PropTypeDefaultDescription
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.
stackSheetDescriptor[]—Open, minimized and closing sheets, bottom to top.

openSheet options

PropTypeDefaultDescription
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.
roundedbooleanstacking === 'inset'Round the sheet’s exposed left corners.
titlestring—Label in the minimized dock. Defaults to the key in sentence case.
dismissiblebooleantrueFalse ignores outside clicks and Escape. The close button and close() still work.
minimizablebooleanfalseClicking outside minimizes the sheet to the dock instead of closing it.
multiplebooleanfalseAllow more than one sheet with this key at a time. Otherwise opening an open key restores that sheet and returns its pending result.
retainDraftbooleanfalseKeep the useSheetDraft state when dismissed with Escape or an outside click, and restore it the next time this key opens.

SheetProvider

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
sheetIdstring—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)

PropTypeDefaultDescription
sheetIdstring—The sheetId prop.
initialT | (() => T)—Used when there is no stored draft.
returns[value, setValue, clear]—Like useState. clear() drops the stored draft without changing the current value.