Overlays & navigation
Sheet
A sheet is a dialog that slides in from the edge of the screen. Use it to edit a record without leaving the list, for filters, or for side panels such as activity and settings. It shares Dialog's native <dialog> foundation, so focus, Escape and the backdrop behave the same; the header and footer stay put while the body scrolls.
export function EditCustomerSheet() { const [open, setOpen] = useState(false); return ( <div className="flex justify-center"> <Button iconLeft={<PanelRight strokeWidth={1.75} />} onClick={() => setOpen(true)}> Edit customer </Button> <Sheet open={open} onClose={() => setOpen(false)}> <SheetHeader title="Edit customer" description="Changes apply to new invoices for Fernhill Studio." /> <SheetBody className="flex flex-col gap-5"> <Field label="Company name"> <Input defaultValue="Fernhill Studio" /> </Field> <Field label="Billing email"> <Input type="email" defaultValue="accounts@fernhill.example" /> </Field> <div className="grid gap-5 sm:grid-cols-2"> <Field label="Currency"> <Select defaultValue="eur"> <option value="eur">EUR</option> <option value="usd">USD</option> <option value="gbp">GBP</option> </Select> </Field> <Field label="Payment terms"> <Select defaultValue="30"> <option value="14">Net 14</option> <option value="30">Net 30</option> <option value="60">Net 60</option> </Select> </Field> </div> <Field label="Billing address"> <Textarea defaultValue={"Fernhill Studio\n12 Quarry Lane\nBristol BS1 4XE"} rows={3} /> </Field> <Field label="Internal notes" optional> <Textarea placeholder="Only your team sees this." rows={3} /> </Field> <Switch label="Send payment reminders" description="3 days before and 7 days after the due date." defaultChecked /> </SheetBody> <SheetFooter> <DialogClose> <Button variant="outline">Cancel</Button> </DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Save changes</Button> </SheetFooter> </Sheet> </div> );}Usage
components/ui/sheet.tsx
import { Sheet, SheetBody, SheetFooter, SheetHeader } from "@/components/ui/sheet"; <Sheet open={open} onClose={() => setOpen(false)}> <SheetHeader title="Edit customer" description="Changes apply to new invoices." /> <SheetBody>…</SheetBody> <SheetFooter> <Button variant="primary" onClick={save}>Save changes</Button> </SheetFooter></Sheet>Examples
Edit a record
A right-side sheet with a form. The body scrolls on short screens; the header and the Save button never leave the view.
export function EditCustomerSheet() { const [open, setOpen] = useState(false); return ( <div className="flex justify-center"> <Button iconLeft={<PanelRight strokeWidth={1.75} />} onClick={() => setOpen(true)}> Edit customer </Button> <Sheet open={open} onClose={() => setOpen(false)}> <SheetHeader title="Edit customer" description="Changes apply to new invoices for Fernhill Studio." /> <SheetBody className="flex flex-col gap-5"> <Field label="Company name"> <Input defaultValue="Fernhill Studio" /> </Field> <Field label="Billing email"> <Input type="email" defaultValue="accounts@fernhill.example" /> </Field> <div className="grid gap-5 sm:grid-cols-2"> <Field label="Currency"> <Select defaultValue="eur"> <option value="eur">EUR</option> <option value="usd">USD</option> <option value="gbp">GBP</option> </Select> </Field> <Field label="Payment terms"> <Select defaultValue="30"> <option value="14">Net 14</option> <option value="30">Net 30</option> <option value="60">Net 60</option> </Select> </Field> </div> <Field label="Billing address"> <Textarea defaultValue={"Fernhill Studio\n12 Quarry Lane\nBristol BS1 4XE"} rows={3} /> </Field> <Field label="Internal notes" optional> <Textarea placeholder="Only your team sees this." rows={3} /> </Field> <Switch label="Send payment reminders" description="3 days before and 7 days after the due date." defaultChecked /> </SheetBody> <SheetFooter> <DialogClose> <Button variant="outline">Cancel</Button> </DialogClose> <Button variant="primary" onClick={() => setOpen(false)}>Save changes</Button> </SheetFooter> </Sheet> </div> );}Sides
Right is the default. Left suits navigation; bottom gets a grab handle and rounded top corners, and works well for filters on phones.
export function SheetSides() { const [side, setSide] = useState<SheetSide | null>(null); return ( <div className="flex flex-wrap justify-center gap-2"> {(["left", "right", "bottom"] as const).map((s) => ( <Button key={s} variant="outline" onClick={() => setSide(s)}>From {s}</Button> ))} <Sheet open={side !== null} onClose={() => setSide(null)} side={side ?? "right"} size="sm"> <SheetHeader title="Filter invoices" description="Show invoices that match any of these." /> <SheetBody className="flex flex-col gap-3"> {filters.map((f) => ( <Switch key={f} label={f} showLabel labelPosition="start" defaultChecked={f !== "Draft"} /> ))} </SheetBody> <SheetFooter> <DialogClose> <Button variant="ghost">Reset</Button> </DialogClose> <DialogClose> <Button variant="primary">Show 42 invoices</Button> </DialogClose> </SheetFooter> </Sheet> </div> );}Side panel with actions
`actions` puts extra controls in the header, before the close button. A long list scrolls inside the body.
<Sheet open={open} onClose={() => setOpen(false)} size="sm"> <SheetHeader title="Activity" description={read ? "You're all caught up." : "4 unread"} actions={ <Button size="icon-sm" variant="ghost" aria-label="Mark all as read" onClick={() => setRead(true)}> <CheckCheck strokeWidth={1.75} /> </Button> } /> <SheetBody className="p-0"> <ul className="m-0 list-none divide-y divide-line p-0"> {activity.map((a, i) => ( <li key={`${a.who}-${a.what}`} className="m-0 flex gap-3 px-5 py-3.5"> <Avatar name={a.who} size="sm" decorative /> <p className="m-0 min-w-0 flex-1 text-[13px] leading-5 text-muted"> <span className="font-medium text-fg">{a.who}</span> {a.what} <span className="block text-subtle">{a.when}</span> </p> {!read && i < 4 ? ( <span className="mt-1.5 size-2 shrink-0 rounded-full bg-accent"> <span className="sr-only">Unread</span> </span> ) : null} </li> ))} </ul> </SheetBody></Sheet>Props
Sheet
PropTypeDefaultDescription
openrequiredbooleandefault: —Whether the sheet is shown.onCloserequired() => voiddefault: —Called for Escape, a backdrop click, the × button and DialogClose.side"right" | "left" | "bottom"default: "right"Edge the sheet slides in from.size"sm" | "md" | "lg"default: "md"Width of left and right sheets from 640px up: 384, 448 or 576px. Phones get the full width minus 40px.aria-labelstringdefault: —Name for sheets without a SheetHeader.
SheetHeader
PropTypeDefaultDescription
titlerequiredReactNodedefault: —Heading and accessible name of the sheet.descriptionReactNodedefault: —Supporting line, linked with aria-describedby.actionsReactNodedefault: —Extra buttons placed before the close button.hideClosebooleandefault: falseHide the × button.
SheetBody, SheetFooter
PropTypeDefaultDescription
SheetBody{ className, children }default: —Scrolling content with 20px padding. Pass p-0 for edge-to-edge lists.SheetFooter{ className, children }default: —Pinned actions, right-aligned. Respects the iPhone home indicator.
Accessibility
- A sheet is a modal <dialog>: the page behind is inert, Tab cycles inside the sheet and focus returns to the trigger on close.
- Escape and a click on the backdrop close the sheet. The × button is labelled “Close”.
- SheetHeader's title names the sheet and its description is announced with it.
- The slide animation is turned off when the reader prefers reduced motion.