Skip to content

Sheet

A panel that slides in from an edge for details and secondary tasks.

Status
Stable
Category
Overlays
Adoption
Not used yet
import { Sheet } from "@oration/canon/components/sheet";
packages/canon/src/components/sheet.tsx
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 footer holds the sheet's one filled button, last in the row. Links out, such as Open supplier, are ghost buttons on the left.

The Scroll Edge Rule

The header and footer stay put and the body scrolls between them, with hairlines marking where the scroll area starts and ends.

Anatomy#

Northwind Freight

Supplier since 2021

  1. Scrim. Black at 25% (55% in dark), fading over 200ms. Clicking it closes the sheet.
  2. 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.
  3. Close button. A 28px ghost icon button 12px from the top right, named Close.
  4. Header. SheetHeader, 16px padding, with SheetTitle (16px, weight 500) and SheetDescription (14px Slate Meta) 2px apart. The product adds border-b when the body scrolls.
  5. Body. Your content. For long bodies, min-h-0 flex-1 overflow-y-auto p-4 so it scrolls between the header and footer.
  6. Footer. SheetFooter, pushed to the bottom with mt-auto, 16px padding, stacked by default. The product passes flex-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#

States
StateTreatment
ClosedNothing is rendered.
OpeningSlides 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.
OpenFocus is inside and trapped, the page is inert and its scroll locked.
ClosingSlides 2.5rem back toward its edge and fades in 200ms, faster than it came in.
Reduced motionOnly opacity transitions. The sheet fades in and out in place.

Behavior#

  • Built on Base UI Dialog, so it behaves like one: SheetTrigger opens it; Esc, the scrim, the close button and any SheetClose close 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 header border-b, the body min-h-0 flex-1 overflow-y-auto p-4 and the footer border-t.
  • Width is a class: the product passes w-full with sm:max-w-md (28rem) or sm: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.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA 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 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA 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 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, 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 defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

Edit Halcyon

Legal name
EIN
Email
Street
City
Bank
Do. Pin the header and footer and scroll only the body, so the title and Save are always in view.

Edit Halcyon

Legal name
EIN
Email
Street
City
Bank
Don't. Let the whole panel scroll. The title and Save scroll away and people hunt for them.
Do. Open details from the row they belong to, and keep the list visible behind the scrim.
Don't. Use a sheet for a yes or no question. It's a lot of surface for one sentence; use a confirmation.
Do. Put the one filled button in the footer, right-aligned, with Cancel beside it.
Don't. Put Save in the header next to the close button. It's easy to hit the wrong one.

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 by SheetTitle and described by SheetDescription. Every sheet needs a title; hide it with sr-only if 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.
Keyboard interactions
KeysAction
EnterOpens the sheet from its trigger.
TabMoves through the controls inside, wrapping at the ends.
EscCloses the sheet and returns focus.

Design tokens#

Design tokens
TokenUsed for
--popoverPanel fill
shadow-lgThe dialog shadow
--borderThe inner-edge hairline, header and footer rules
--muted-foregroundSheetDescription
--ease-drawercubic-bezier(0.32, 0.72, 0, 1), 320ms in and 200ms out
bg-black/25The 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.

Props of Sheet
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes.
onOpenChangeComplete(open: boolean) => voidNo defaultCalled after the animation ends. Clear the selected record here.
modalboolean | "trap-focus"trueLeave it on.
disablePointerDismissalbooleanfalseKeeps a click on the scrim from closing it.

SheetTrigger

The button that opens it.

Other props spread onto Base UI Dialog.Trigger (<button>).

Props of SheetTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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.

Props of SheetContent
PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left""right"The edge it slides in from.
showCloseButtonbooleantrueDraws the close button in the top right.
initialFocusboolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on open.
finalFocusboolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on close.
classNamestringNo defaultMerged 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.

SheetFooter

Pushed to the bottom with mt-auto, 16px padding, stacked.

Other props spread onto <div>.

No props of its own.

SheetClose

Closes the sheet. Render a button through it.

Other props spread onto Base UI Dialog.Close (<button>).

Props of SheetClose
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultUsually <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.