Sheet

Slides in a panel to edit one thing in place.

Examples

Edit an agent

Review a refund

Actions on a phone

Side

  • enddefault

  • start

  • bottom

  • top

Usage

When to use it
Use
Editing one item from a list the person wants in mind: an agent, an integration.
Use
Showing the details of the selected row next to its list (docked).
Use
Navigation or a few actions on a phone, from the left or the bottom.
Not for
A yes or no question, or a form of up to three fields. Use Dialog.
Not for
Content of a few lines next to its control. Use Popover.
Not for
The panel is the main subject of the page. Use a route.

Rules

12 rules
Must
Follow the dialog shell: grey outer with a quiet frame line, one white card 4px in that holds the title, description and close and goes on as the body, and the actions on the grey. Because the grey gives the actions a ground of their own, and the white card is the one group that carries the heading and the rows, so the frame never needs a rule under its header.
Never
Put a white Card inside a sheet; the inset is the only white surface inside it. Because a white card inside the sheet's grey is a second box in the box.
Must
Title the sheet with the item's name or ID and put context in the description. Because the title is the sheet's accessible name, so "Details" tells a screen reader nothing about which ticket is open.
Must
Put the middle in SheetBody; it is the only part that scrolls and fades. Because SheetBody is the only part that scrolls and fades, so without it the footer scrolls off with the fields.
Should
Use modal={false} with stackBelow for the details of a selected row; a modal sheet hides the list.
Should
Store the width from onWidthChange per surface and pass it back as defaultWidth on a resizable docked panel.
Must
Give actions in a side="bottom" sheet size="lg" (44px). Because a bottom sheet is the phone layout, and a 28px sm button is under the 44px thumb target.
Should
Pass mono to SheetField for an ID, hash or URL.
Must
Open a docked panel with SheetTrigger, or close it with SheetClose, so Escape can return focus. Because the docked panel returns focus to the element SheetTrigger registered, so a plain button leaves focus lost on Escape.
Never
Add border-l to a docked panel; it is already a frame in the layout. Because the docked panel draws its own frame line, and a border on top doubles the edge.
Never
Wrap the body in a scroll library; SheetBody has native overflow and a fade. Because a second scroller inside SheetBody hides the edge fade and gives the keyboard two scroll areas.
Should
Keep minWidth at 280px or more and stack under 3xl of the container.

Accessibility

7 checks
A modal panel is role="dialog" with aria-modal, named by SheetTitle; the page behind is inert and Tab stays inside.
A docked panel is an aside named by SheetTitle; it does not trap focus.
Escape closes it, a menu or popover inside closes first, and nothing closes while an action is pending.
Focus returns to the trigger on close.
The docked handle is role="separator" with aria-valuenow, min, max; arrows move 32px, Shift 8px, Home and End jump, Enter resets.
The body is a tab stop only while it has more to scroll.
A rejected onAction shows a role="alert" message above the footer.

Edge cases

11 edge cases
60 fields
The body scrolls inside; header and footer stay; the fade shows only at an edge with more.
Slow save
The action shows pending, Escape and Close wait, and the sheet stays open.
Failed save
The sheet stays open with the message above the footer; fields keep their input.
Right to left
side="end" comes from the left and swipes back to it; a docked panel takes the wrapper dir, its handle on the edge facing the list.
Long trace ID
A mono field breaks anywhere and wraps.
Long title
Wraps in the modal sheet; truncates to one line in a docked one.
No title
The panel is unnamed. Always render SheetTitle.
Zoom 200%
A docked panel stacks under the list; a modal body scrolls.
Menu inside
Escape closes the menu first; a docked panel ignores Escape from an open menu or dialog.
Forced colors
The panel gets a 1px CanvasText outline; a docked frame's line survives as CanvasText.
Reduced motion
Fades instead of travelling its 8px; a swipe still follows the finger.

API

11 props
Sheet modal
boolean default true
Sheet open / defaultOpen / onOpenChange
boolean, (open) => void default false
SheetContent side
'start' | 'end' | 'top' | 'right' | 'bottom' | 'left' default 'end'
SheetContent showCloseButton
boolean default true
SheetContent kind
Kind
SheetContent defaultWidth / minWidth / maxWidth
number default 360 / 280 / 640
SheetContent onWidthChange
(width) => void
SheetContent stackBelow
'md' to '5xl'
SheetFooter defaultAction
'primary' | 'dismiss' | 'none' default auto
SheetAction onAction
() => void | Promise
SheetField mono
boolean default false

Tokens

27 tokens
--surface-overlay
oklch(var(--ink-c) / 0.24)
--surface-page
var(--paper)
--elev-overlay
0 0 0 1px var(--line-frame), 0 12px 24px -12px oklch(var(--text-on-field-c) / var(--shadow-alpha))
--overlay
480px
--space-10
40px
--radius-soft
6px
--radius-frame
10px
--line-frame
oklch(var(--ink-c) / var(--line-frame-alpha))
--line-row
oklch(var(--ink-c) / 0.08)
--line-control
oklch(var(--ink-c) / 0.16)
--accent-mark
var(--ink)
--dur-slow
180ms
--dur-base
160ms
--sheet-shift
8px
--ease-step
cubic-bezier(0.2, 0, 0, 1)
--ink
oklch(var(--ink-c))
--dur-fast
120ms
--surface-inverse
var(--ink)
--text-inverse
var(--paper)
--type-state-word
400 14px/16px var(--font-sans)
--accent-wash
var(--surface-selected)
--text-1
var(--ink)
--text-3
oklch(var(--ink-c) / 0.66)
--type-label
400 14px/16px var(--font-sans)
--type-code-sm
400 12px/16px var(--font-mono)
--type-body-sm
380 13px/18px var(--font-sans)
--grab-size
40px

Install

For your coding agent

GitHub

Please star me pookie

141

A new design system every week

Leave your email before you copy the prompt.