opsinjs
ComponentsSurfaces

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

Theme
Density
Text
Status
sheet · base base · style base-lyraOpen under the product theme

Installation

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-sheet instead.
  • Asking permission to collect or share data.Use consent-sheet instead.
  • A decision the reader must make before continuing, at any width.Use dialog instead.
  • A panel that stays put while the reader works behind it.Use surface instead.
  • A small menu anchored to a control.Use popover instead.

Anatomy

  1. Sheet.ScrimOnly when modal; a Surface at the scrim rung.Controlled by modal
  2. Sheet.ContainerThe popup: top corner, slide and detent transform.Controlled by className
  3. Sheet.GrabberA real button above one detent, named for the next height.Controlled by detents
  4. Sheet.HeaderTitle and close control, above the scroll boundary.
  5. Sheet.TitleAn h2; what aria-labelledby points at. Fixed level.Controlled by title
  6. Sheet.CloseThe one route dismissible={false} never cancels. Visibly labelled.
  7. Sheet.ContentThe caller's scrolling region. Contains overscroll. A focus stop.
  8. Sheet.DescriptionWhat aria-describedby points at, at most one, never empty.
  9. Sheet.FooterPinned actions, above the keyboard and safe-area inset.Controlled by footer

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.

Theme
Density
Text
Status
sheet-a-form-that-stays-put · base base · style base-lyraOpen under the product theme

Two 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.

Theme
Density
Text
Status
sheet-two-detents-and-a-control · base base · style base-lyraOpen under the product theme

Accessibility

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 is aria-hidden, not inert.
  • 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-motion removes 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.
KeyActionNotes
TabMoves through the sheet's own controlsTrapped in a modal sheet.
Shift+TabMoves backwards through the same controlsReverse order; footer stays last.
EnterActivates the focused controlMoves the grabber to the next detent.
SpaceActivates the focused button, or scrolls the contentScrolls Sheet.Content, not the page.
EscapeCloses a dismissible sheet and returns focus to what opened itWith dismissible={false}, returns to the container.
Arrow keysScroll the content, once focus is in the scrolling regionOnly the grabber changes detent.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor
body text on the canvas materiallight104.717.96:1Pass
body text on the canvas materialdark-100.518.00:1Pass
body text on the inset materiallight98.116.32:1Pass
body text on the inset materialdark-100.117.18:1Pass
body text on the card materiallight104.717.96:1Pass
body text on the card materialdark-99.616.32:1Pass
body text on the raised materiallight104.717.96:1Pass
body text on the raised materialdark-97.513.77:1Pass
body text on the sheet material over the darkest backdroplight99.916.76:1Pass
body text on the sheet material over the darkest backdropdark-99.716.47:1Pass
body text on the sheet material over the lightest backdroplight104.717.96:1Pass
body text on the sheet material over the lightest backdropdark-80.86.07:1Pass
body text on the overlay material over the darkest backdroplight85.913.48:1Pass
body text on the overlay material over the darkest backdropdark-99.916.77:1Pass
body text on the overlay material over the lightest backdroplight104.717.96:1Pass
body text on the overlay material over the lightest backdropdark-54.22.69:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

Data attributes

AttributeConditionValue
data-slotOn every rendered partsheet-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-styleOn the viewport, the scrim and the containerpresent
data-expandedWhile the active detent is fullpresent
data-swiping, data-swipe-dismiss, data-swipe-directionDuring a drag; dismiss means release would close itpresent, direction down
data-drawer-contentAlways; makes a touch there scroll, not swipepresent

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.

  • 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.

On this page