---
title: "Sheet"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/sheet"
source: "https://opsinjs.pensievelabs.org/components/sheet.md"
section: "Components"
status: "shipped"
kind: "component"
category: "surfaces"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["drawer", "bottom sheet", "modal sheet", "detent", "side panel", "slide over"]
usedIn: ["alert-escalation", "consent-and-permissions", "consent-flow", "daily-log-entry", "daily-log-screen", "daily-logging", "diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="sheet"
  status="shipped"
  questions="[
  &#x22;No prop translates the two English strings Sheet ships. Where does a component's own copy live outside English?&#x22;,
  &#x22;The software-keyboard handling has never run on a real device, so the uncovered primary action is only argued.&#x22;,
  &#x22;Nothing here was measured in a browser. What gets measured first: hit areas, a real keyboard, or iOS scrolling?&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="sheet" />

## Installation [#installation]

<ComponentInstall name="sheet" unbuilt="false" importPath="@/components/ui/sheet" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" registryDependencies="[&#x22;surface&#x22;, &#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { Sheet } from "@/components/ui/sheet"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A focused task started from the current screen, such as capturing a reading.&#x22;,
  &#x22;Content reachable one-handed, where the thumb is.&#x22;,
  &#x22;A surface whose height follows its content, not the viewport.&#x22;,
]"
  avoid="[
  { case: &#x22;A repeated daily log entry, with its own contract and ceiling.&#x22;, instead: &#x22;log-sheet&#x22; },
  { case: &#x22;Asking permission to collect or share data.&#x22;, instead: &#x22;consent-sheet&#x22; },
  { case: &#x22;A decision the reader must make before continuing, at any width.&#x22;, instead: &#x22;dialog&#x22; },
  { case: &#x22;A panel that stays put while the reader works behind it.&#x22;, instead: &#x22;surface&#x22; },
  { case: &#x22;A small menu anchored to a control.&#x22;, instead: &#x22;popover&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="sheet"
  parts="[
  {
    name: &#x22;Sheet.Scrim&#x22;,
    describes: &#x22;Only when modal; a Surface at the scrim rung.&#x22;,
    prop: &#x22;modal&#x22;,
  },
  {
    name: &#x22;Sheet.Container&#x22;,
    describes: &#x22;The popup: top corner, slide and detent transform.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;Sheet.Grabber&#x22;,
    describes: &#x22;A real button above one detent, named for the next height.&#x22;,
    prop: &#x22;detents&#x22;,
  },
  {
    name: &#x22;Sheet.Header&#x22;,
    describes: &#x22;Title and close control, above the scroll boundary.&#x22;,
  },
  {
    name: &#x22;Sheet.Title&#x22;,
    describes: &#x22;An h2; what aria-labelledby points at. Fixed level.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;Sheet.Close&#x22;,
    describes: &#x22;The one route dismissible={false} never cancels. Visibly labelled.&#x22;,
  },
  {
    name: &#x22;Sheet.Content&#x22;,
    describes: &#x22;The caller's scrolling region. Contains overscroll. A focus stop.&#x22;,
  },
  {
    name: &#x22;Sheet.Description&#x22;,
    describes: &#x22;What aria-describedby points at, at most one, never empty.&#x22;,
  },
  {
    name: &#x22;Sheet.Footer&#x22;,
    describes: &#x22;Pinned actions, above the keyboard and safe-area inset.&#x22;,
    prop: &#x22;footer&#x22;,
  },
]"
/>

## Examples [#examples]

### A form the reader cannot leave by accident [#a-form-the-reader-cannot-leave-by-accident]

`dismissible={false}` cancels the scrim, Escape and a downward drag, and
returns focus to the container.

<ComponentPreview name="sheet-a-form-that-stays-put" kind="example" align="start" />

### Two detents, and the button the gesture owes [#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.

<ComponentPreview name="sheet-two-detents-and-a-control" kind="example" align="start" />

## Accessibility [#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.

<KeyboardTable
  name="sheet"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves through the sheet's own controls&#x22;,
    notes: &#x22;Trapped in a modal sheet.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves backwards through the same controls&#x22;,
    notes: &#x22;Reverse order; footer stays last.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the focused control&#x22;,
    notes: &#x22;Moves the grabber to the next detent.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the focused button, or scrolls the content&#x22;,
    notes: &#x22;Scrolls Sheet.Content, not the page.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes a dismissible sheet and returns focus to what opened it&#x22;,
    notes: &#x22;With dismissible={false}, returns to the container.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Scroll the content, once focus is in the scrolling region&#x22;,
    notes: &#x22;Only the grabber changes detent.&#x22;,
  },
]"
/>

<ContrastReport component="sheet" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="sheet"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On every rendered part&#x22;,
    value: &#x22;sheet-container, sheet-grabber, sheet-header, sheet-title, sheet-close, sheet-scrim, sheet-content, sheet-description, sheet-footer&#x22;,
  },
  {
    attribute: &#x22;data-open, data-closed, data-starting-style, data-ending-style&#x22;,
    condition: &#x22;On the viewport, the scrim and the container&#x22;,
    value: &#x22;present&#x22;,
  },
  {
    attribute: &#x22;data-expanded&#x22;,
    condition: &#x22;While the active detent is full&#x22;,
    value: &#x22;present&#x22;,
  },
  {
    attribute: &#x22;data-swiping, data-swipe-dismiss, data-swipe-direction&#x22;,
    condition: &#x22;During a drag; dismiss means release would close it&#x22;,
    value: &#x22;present, direction down&#x22;,
  },
  {
    attribute: &#x22;data-drawer-content&#x22;,
    condition: &#x22;Always; makes a touch there scroll, not swipe&#x22;,
    value: &#x22;present&#x22;,
  },
]"
/>

## API reference [#api-reference]

### Sheet [#sheet]

<PropsTable name="SheetProps" />

`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 [#sheetcontent]

<PropsTable name="SheetContentProps" />

### Sheet.Description [#sheetdescription]

<PropsTable name="SheetDescriptionProps" />

## Related [#related]

* [Dialog](./dialog.mdx) is a question that must be answered, not a place chosen.
* [LogSheet](./log-sheet.mdx) is a Sheet with a capture contract and field ceiling.
* [ConsentSheet](./consent-sheet.mdx) is a Sheet with a consent contract and equal-weight decisions.
* [Surface](./surface.mdx) is the layer Sheet is built from, for sheet and scrim.
* [Popover](./popover.mdx) is small, anchored to a control rather than the screen.
