Sheet
A panel that slides in from an edge for details and secondary tasks.
import { Button } from "@oration/canon/components/button";import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from "@oration/canon/components/sheet";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon } from "lucide-react";import * as React from "react";export function Hero() { const invoices = [ { id: "INV-20931", supplier: "Northwind Freight", amount: "$18,240.00", due: "Oct 2", status: "Scheduled", }, { id: "INV-20932", supplier: "Halcyon", amount: "$9,612.50", due: "Oct 2", status: "Scheduled", }, { id: "INV-20935", supplier: "Orchard Street", amount: "$4,120.00", due: "Oct 9", status: "Needs approval", }, ]; const [selected, setSelected] = React.useState<(typeof invoices)[number]>(); const [open, setOpen] = React.useState(false); return ( <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card shadow-border"> <ul className="divide-y divide-border"> {invoices.map((invoice) => ( <li key={invoice.id}> <button type="button" onClick={() => { setSelected(invoice); setOpen(true); }} className="flex h-10 w-full items-center gap-3 px-4 text-left text-13 outline-none transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-3 focus-visible:ring-ring/40 focus-visible:ring-inset" > <span className="w-20 shrink-0 font-mono text-xs text-muted-foreground"> {invoice.id} </span> <span className="min-w-0 flex-1 truncate font-medium"> {invoice.supplier} </span> <span className="tabular-nums"> {invoice.amount} </span> </button> </li> ))} </ul> <Sheet open={open} onOpenChange={setOpen}> <SheetContent className="w-full gap-0 sm:max-w-md"> {selected ? ( <> <SheetHeader className="border-b border-border pr-12"> <SheetTitle>{selected.id}</SheetTitle> <SheetDescription> {selected.supplier}, due Friday,{" "} {selected.due}. </SheetDescription> </SheetHeader> <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-4"> <div className="grid grid-cols-2 gap-2"> <div className="flex flex-col gap-1 rounded-[10px] bg-muted/70 p-3"> <span className="text-xs text-muted-foreground"> Amount </span> <span className="text-base font-medium tabular-nums"> {selected.amount} </span> </div> <div className="flex flex-col gap-1 rounded-[10px] bg-muted/70 p-3"> <span className="text-xs text-muted-foreground"> Status </span> <StatusLabel tone={ selected.status === "Scheduled" ? "info" : "warning" } > {selected.status} </StatusLabel> </div> </div> <section className="flex flex-col gap-2"> <h3 className="text-13 font-medium"> Details </h3> <dl className="grid grid-cols-[8rem_1fr] gap-y-2 text-13"> <dt className="text-muted-foreground"> Issued </dt> <dd className="tabular-nums"> Sep 2, 2026 </dd> <dt className="text-muted-foreground"> Terms </dt> <dd>Net 30</dd> <dt className="text-muted-foreground"> Approved by </dt> <dd>Priya Raman</dd> <dt className="text-muted-foreground"> Payment run </dt> <dd className="font-mono text-xs leading-5"> PR-0412 </dd> </dl> </section> </div> <SheetFooter className="flex-row items-center border-t border-border"> <Button type="button" variant="ghost" size="sm" className="mr-auto text-muted-foreground" onClick={() => toast.add({ title: `Opened ${selected.supplier}`, }) } > Open supplier <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" /> </Button> <SheetClose render={ <Button type="button" variant="outline" size="sm" /> } > Close </SheetClose> <Button type="button" size="sm" onClick={() => { setOpen(false); toast.add({ title: `${selected.id} held`, description: "It stays out of payment runs until released.", }); }} > Hold invoice </Button> </SheetFooter> </> ) : null} </SheetContent> </Sheet> </div> );}Usage#
Sheet is a panel that slides in from an edge of the screen, usually the right, for record details and longer edits that belong beside the list they came from: an invoice opened from the payables table, a supplier's bank details, a queue's routing. It is modal, like a dialog, but its shape says "this is about the row you clicked" and it has room for a scrolling body. It is the most used overlay in the product. The common mistake is letting the whole panel scroll, so the Save button scrolls away: pin the header and footer and scroll only the body.
When to use
- To show a record's details from a table row or card without leaving the list: an invoice, a supplier, a payment run.
- For an edit with more fields than a dialog holds, such as a supplier's remit-to and banking details.
- For logs, histories and timelines that scroll, such as the activity on a remittance.
- On phones, for navigation that would be a sidebar on desktop, from the left.
When not to use
- For a short task the page waits on, such as editing payment terms. Use Dialog
- For confirming a delete or another consequential action. Use Confirm dialog
- For a touch-first panel people expect to swipe away. Use Drawer
- For a few controls tied to one button that apply as you change them. Use Popover
- For details that should stay open while people keep working in the list. Use a resizable split view instead. Use Resizable panels
The One Filled Button Rule
The Scroll Edge Rule
Anatomy#
Northwind Freight
Supplier since 2021
- Scrim. Black at 25% (55% in dark), fading over 200ms. Clicking it closes the sheet.
- Panel.
SheetContent: Popover White, the dialog shadow and a hairline on the inner edge. From the left or right it is full height, 75% wide on phones and 24rem from 640px; from the top or bottom it is full width and as tall as its content. - Close button. A 28px ghost icon button 12px from the top right, named Close.
- Header.
SheetHeader, 16px padding, withSheetTitle(16px, weight 500) andSheetDescription(14px Slate Meta) 2px apart. The product addsborder-bwhen the body scrolls. - Body. Your content. For long bodies,
min-h-0 flex-1 overflow-y-auto p-4so it scrolls between the header and footer. - Footer.
SheetFooter, pushed to the bottom withmt-auto, 16px padding, stacked by default. The product passesflex-row justify-end border-t border-border.
Examples#
Sides
Right is the default and the product's choice for record details. Left is for navigation on phones; top and bottom sheets span the width and size to their content. Each slides 2.5rem from its edge over 320ms and leaves in 200ms.
import { Button } from "@oration/canon/components/button";import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger,} from "@oration/canon/components/sheet";import { cn } from "@oration/canon/lib/utils";export function Sides() { const sides = ["top", "right", "bottom", "left"] as const; return ( <> {sides.map((side) => ( <Sheet key={side}> <SheetTrigger render={ <Button type="button" variant="outline" size="sm" /> } > <span className="capitalize">{side}</span> </SheetTrigger> <SheetContent side={side}> <SheetHeader className="pr-12"> <SheetTitle>Filters</SheetTitle> <SheetDescription> Opened from the {side}. Side sheets are full height; top and bottom sheets are as tall as their content. </SheetDescription> </SheetHeader> <SheetFooter className={cn( side === "top" || side === "bottom" ? "flex-row justify-end" : undefined, )} > <SheetClose render={ <Button type="button" variant="outline" size="sm" /> } > Close </SheetClose> </SheetFooter> </SheetContent> </Sheet> ))} </> );}Pinned header and footer
The product's edit sheet: gap-0 on the content, a hairline under the header, a body that scrolls on its own and a footer row over a hairline. Wrap all three in the form so Enter saves.
import { Button } from "@oration/canon/components/button";import { Field, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger,} from "@oration/canon/components/sheet";import { toast } from "@oration/canon/components/toast";import { PencilIcon } from "lucide-react";import * as React from "react";export function PinnedRegions() { const [open, setOpen] = React.useState(false); const ids = { legal: React.useId(), tax: React.useId(), email: React.useId(), street: React.useId(), city: React.useId(), bank: React.useId(), routing: React.useId(), account: React.useId(), }; return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger render={<Button type="button" variant="outline" />}> <PencilIcon data-icon="inline-start" aria-hidden="true" /> Edit supplier </SheetTrigger> <SheetContent className="w-full gap-0 sm:max-w-md"> <form className="flex min-h-0 flex-1 flex-col" onSubmit={(event) => { event.preventDefault(); setOpen(false); toast.add({ type: "success", title: "Halcyon updated", description: "Changes apply from the next payment run.", }); }} > <SheetHeader className="border-b border-border pr-12"> <SheetTitle>Edit Halcyon</SheetTitle> <SheetDescription> Legal, remit-to and banking details. </SheetDescription> </SheetHeader> <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain p-4"> <section className="flex flex-col gap-4"> <h3 className="text-13 font-medium">Legal</h3> <Field> <FieldLabel htmlFor={ids.legal}> Legal name </FieldLabel> <Input id={ids.legal} defaultValue="Halcyon Logistics LLC" /> </Field> <Field> <FieldLabel htmlFor={ids.tax}>EIN</FieldLabel> <Input id={ids.tax} defaultValue="84-2910375" className="font-mono" /> </Field> </section> <section className="flex flex-col gap-4"> <h3 className="text-13 font-medium">Remit-to</h3> <Field> <FieldLabel htmlFor={ids.email}> Email </FieldLabel> <Input id={ids.email} type="email" defaultValue="ar@halcyonlogistics.com" /> </Field> <Field> <FieldLabel htmlFor={ids.street}> Street </FieldLabel> <Input id={ids.street} defaultValue="4400 Harbor Way" /> </Field> <Field> <FieldLabel htmlFor={ids.city}>City</FieldLabel> <Input id={ids.city} defaultValue="Alameda, CA 94501" /> </Field> </section> <section className="flex flex-col gap-4"> <h3 className="text-13 font-medium">Banking</h3> <Field> <FieldLabel htmlFor={ids.bank}>Bank</FieldLabel> <Input id={ids.bank} defaultValue="Wells Fargo" /> </Field> <Field> <FieldLabel htmlFor={ids.routing}> Routing number </FieldLabel> <Input id={ids.routing} defaultValue="121000248" className="font-mono" /> </Field> <Field> <FieldLabel htmlFor={ids.account}> Account number </FieldLabel> <Input id={ids.account} defaultValue="000938120932" className="font-mono" /> </Field> </section> </div> <SheetFooter className="mt-0 flex-row justify-end border-t border-border"> <SheetClose render={ <Button type="button" variant="outline" size="sm" /> } > Cancel </SheetClose> <Button type="submit" size="sm"> Save changes </Button> </SheetFooter> </form> </SheetContent> </Sheet> );}Widths
24rem by default. The product passes w-full with sm:max-w-md or sm:max-w-lg most, and sm:max-w-xl for logs and transcripts.
import { Button } from "@oration/canon/components/button";import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger,} from "@oration/canon/components/sheet";export function Widths() { const widths = [ { label: "24rem", className: undefined }, { label: "28rem", className: "w-full sm:max-w-md" }, { label: "32rem", className: "w-full sm:max-w-lg" }, { label: "36rem", className: "w-full sm:max-w-xl" }, ]; return ( <> {widths.map((width) => ( <Sheet key={width.label}> <SheetTrigger render={ <Button type="button" variant="outline" size="sm" /> } > {width.label} </SheetTrigger> <SheetContent className={width.className}> <SheetHeader className="pr-12"> <SheetTitle>Remittance RMT-4471</SheetTitle> <SheetDescription> {width.className ? `Passes ${width.className}.` : "The default: 75% of a phone, 24rem from 640px."} </SheetDescription> </SheetHeader> <SheetFooter className="flex-row justify-end"> <SheetClose render={ <Button type="button" variant="outline" size="sm" /> } > Close </SheetClose> </SheetFooter> </SheetContent> </Sheet> ))} </> );}States#
| State | Treatment |
|---|---|
| Closed | Nothing is rendered. |
| Opening | Slides 2.5rem in from its edge while fading in, over 320ms on the drawer curve, cubic-bezier(0.32, 0.72, 0, 1). The scrim fades in over 200ms. |
| Open | Focus is inside and trapped, the page is inert and its scroll locked. |
| Closing | Slides 2.5rem back toward its edge and fades in 200ms, faster than it came in. |
| Reduced motion | Only opacity transitions. The sheet fades in and out in place. |
Behavior#
- Built on Base UI Dialog, so it behaves like one:
SheetTriggeropens it; Esc, the scrim, the close button and anySheetCloseclose it. - Opening moves focus to the first focusable element inside. Closing returns focus to the trigger, or to whatever had focus before.
- Opened from a table row, it is controlled: keep the selected record in state and pass
open={record !== null}. Keep the record until the close animation finishes, so the panel doesn't empty as it leaves. - The panel is a flex column with 16px gaps. For a pinned header and footer, pass
gap-0, give the headerborder-b, the bodymin-h-0 flex-1 overflow-y-auto p-4and the footerborder-t. - Width is a class: the product passes
w-fullwithsm:max-w-md(28rem) orsm:max-w-lg(32rem) most. Top and bottom sheets take their height from their content. - Timings come from globals.css, keyed on
data-slot="sheet-content": 320ms in on the drawer curve and 200ms out. Under reduced motion only opacity transitions.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet (this page) | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
Edit Halcyon
Edit Halcyon
Content#
- The title is the record's name (INV-20931, Northwind Freight) or the task (Edit banking details).
- The description adds the one fact that places the record: Due Friday, Oct 2, in payment run PR-0412.
- Section headings inside are 13px medium, sentence case: Payment history, Remit-to.
- Footer buttons follow Button: Save changes, Cancel, and Close when nothing can change.
Accessibility#
- The panel is
role="dialog", labelled bySheetTitleand described bySheetDescription. Every sheet needs a title; hide it withsr-onlyif the design has none. - Focus moves in on open, is trapped while open and returns to the trigger on close. When the trigger was a table row, make the row focusable so focus has somewhere to go back to.
- The page behind is inert while it's open.
- The close button is named Close and is 28px.
- Under reduced motion only opacity changes.
| Keys | Action |
|---|---|
| Enter | Opens the sheet from its trigger. |
| Tab | Moves through the controls inside, wrapping at the ends. |
| Esc | Closes the sheet and returns focus. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Panel fill |
shadow-lg | The dialog shadow |
--border | The inner-edge hairline, header and footer rules |
--muted-foreground | SheetDescription |
--ease-drawer | cubic-bezier(0.32, 0.72, 0, 1), 320ms in and 200ms out |
bg-black/25 | The scrim, bg-black/55 in dark |
API reference#
Sheet
The root. Holds the open state and renders no element.
Other props spread onto Base UI Dialog.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes. |
onOpenChangeComplete | (open: boolean) => void | No default | Called after the animation ends. Clear the selected record here. |
modal | boolean | "trap-focus" | true | Leave it on. |
disablePointerDismissal | boolean | false | Keeps a click on the scrim from closing it. |
SheetTrigger
The button that opens it.
Other props spread onto Base UI Dialog.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own button, such as <Button variant="outline" />. |
SheetContent
The panel, rendered in a portal with the scrim and the close button.
Other props spread onto Base UI Dialog.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "right" | The edge it slides in from. |
showCloseButton | boolean | true | Draws the close button in the top right. |
initialFocus | boolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | void | No default | Where focus goes on open. |
finalFocus | boolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | void | No default | Where focus goes on close. |
className | string | No default | Merged after the defaults. Widths such as w-full sm:max-w-md, and gap-0 for pinned regions. |
SheetHeader
16px padding, title and description 2px apart.
Other props spread onto <div>.
No props of its own.
SheetTitle
Labels the sheet. 16px at weight 500.
Other props spread onto Base UI Dialog.Title (<h2>).
No props of its own.
SheetDescription
Describes the sheet. 14px Slate Meta.
Other props spread onto Base UI Dialog.Description (<p>).
No props of its own.
SheetClose
Closes the sheet. Render a button through it.
Other props spread onto Base UI Dialog.Close (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Usually <Button variant="outline" />. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The panel draws a CSS border on its inner edge as well as the dialog shadow. The Hairline-and-Lift Rule says a raised surface takes its edge from one composite shadow, not a border plus a shadow.
SheetFooter is a plain padded row, not the Well Gray band Dialog uses, and it stacks by default. The product passes flex-row justify-end border-t border-border by hand in most of its sheets.
SheetTitle has the default line height; DESIGN.md sets Title Large at line-height 1 for dialog and sheet titles, and DialogTitle follows it.
The component's own classes say duration-200 ease-drawer. globals.css overrides them to 320ms in and 200ms out through data-slot, so a hand-rolled panel without the slot animates at 200ms both ways.
Side sheets are 75% wide on phones. The product almost always passes w-full.
SheetPortal and the overlay are defined but not exported.