Sheet
A panel that slides up from the bottom, stops at sensible heights, and can be dragged or dismissed. The one-handed way to act without leaving the page.
Preview
sheet · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/sheet
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Sheet } from "@/components/ui/sheet"<Sheet open={open} onOpenChange={setOpen} title="Add a reading">
<Sheet.Content>
<Sheet.Description>What this sheet is for.</Sheet.Description>
{/* … */}
</Sheet.Content>
</Sheet>When to use it
Use it when
- A focused task started from the current screen, such as capturing a reading.
- Content reachable one-handed, where the thumb is.
- A surface whose height follows its content, not the viewport.
Do not use it when
- A repeated daily log entry, with its own contract and ceiling.Use
log-sheetinstead. - Asking permission to collect or share data.Use
consent-sheetinstead. - A decision the reader must make before continuing, at any width.Use
dialoginstead. - A panel that stays put while the reader works behind it.Use
surfaceinstead. - A small menu anchored to a control.Use
popoverinstead.
Anatomy
Sheet.ScrimOnly when modal; a Surface at the scrim rung.Controlled bymodalSheet.ContainerThe popup: top corner, slide and detent transform.Controlled byclassNameSheet.GrabberA real button above one detent, named for the next height.Controlled bydetentsSheet.HeaderTitle and close control, above the scroll boundary.Sheet.TitleAn h2; what aria-labelledby points at. Fixed level.Controlled bytitleSheet.CloseThe one route dismissible={false} never cancels. Visibly labelled.Sheet.ContentThe caller's scrolling region. Contains overscroll. A focus stop.Sheet.DescriptionWhat aria-describedby points at, at most one, never empty.Sheet.FooterPinned actions, above the keyboard and safe-area inset.Controlled byfooter
Examples
A form the reader cannot leave by accident
dismissible={false} cancels the scrim, Escape and a downward drag, and
returns focus to the container.
sheet-a-form-that-stays-put · base base · style base-lyraOpen under the product themeTwo detents, and the button the gesture owes
A second detents entry makes the grabber a real button, moving the sheet
between half and full with no pointer.
sheet-two-detents-and-a-control · base base · style base-lyraOpen under the product themeAccessibility
Audited against WCAG 2.2 AA in a source and a rendered pass, author-run rather than independent, with clinical review pending. The rendered pass found no axe violation, no target-size breach, no missed focus ring and no reflow break at 320px. The source pass required no changes.
- A modal sheet takes the dialog role, named by
Sheet.Title, and traps focus; the page behind isaria-hidden, notinert. - Every gesture has a control: close for drag-to-dismiss, grabber for drag-between-detents.
- The footer sits outside the scrolling region and inside the keyboard inset, keeping the primary action visible.
prefers-reduced-motionremoves distance, not transition: it crossfades in place, and the drag still works.
Residual:
- Focus rings use a negative outline offset to stay visible inside the clipped popup, meeting 2.4.7 and 1.4.11.
- No live region announces the new height; the grabber's accessible name updates to name the next action instead.
- Non-modal mode (
modal={false}) pins the sheet to the bottom while the page behind stays focusable, an unchecked obscured-focus shape; default and demo stay modal.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves through the sheet's own controls | Trapped in a modal sheet. |
| Shift+Tab | Moves backwards through the same controls | Reverse order; footer stays last. |
| Enter | Activates the focused control | Moves the grabber to the next detent. |
| Space | Activates the focused button, or scrolls the content | Scrolls Sheet.Content, not the page. |
| Escape | Closes a dismissible sheet and returns focus to what opened it | With dismissible={false}, returns to the container. |
| Arrow keys | Scroll the content, once focus is in the scrolling region | Only the grabber changes detent. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On every rendered part | sheet-container, sheet-grabber, sheet-header, sheet-title, sheet-close, sheet-scrim, sheet-content, sheet-description, sheet-footer |
| data-open, data-closed, data-starting-style, data-ending-style | On the viewport, the scrim and the container | present |
| data-expanded | While the active detent is full | present |
| data-swiping, data-swipe-dismiss, data-swipe-direction | During a drag; dismiss means release would close it | present, direction down |
| data-drawer-content | Always; makes a touch there scroll, not swipe | present |
API reference
Sheet
Prop
Type
Generated from SheetProps in registry/bases/base/sheet.tsx.
detents defaults to ["content"]; its first entry is where the sheet opens.
half and full combine, content stands alone. onOpenChange receives the
close route second, so a product can ask before discarding unsaved work.
Sheet.Content
Prop
Type
Generated from SheetContentProps in registry/bases/base/sheet.tsx.
Sheet.Description
Prop
Type
Generated from SheetDescriptionProps in registry/bases/base/sheet.tsx.
Related
- Dialog is a question that must be answered, not a place chosen.
- LogSheet is a Sheet with a capture contract and field ceiling.
- ConsentSheet is a Sheet with a consent contract and equal-weight decisions.
- Surface is the layer Sheet is built from, for sheet and scrim.
- Popover is small, anchored to a control rather than the screen.