Overlays

Sheet

A panel that slides in from an edge and is dragged back to dismiss: the mobile form of a dialog. It stretches when pulled past its edge, its backdrop lightens as it is dragged away, and it springs back on release. It handles focus, Escape, scroll locking, safe areas and the software keyboard.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/sheet#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/sheet

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/sheet#v1.2.13 ourostack/ouro-ui/sheet-tests#v1.2.13
import { Sheet } from "@/components/ouro/sheet/sheet";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Sheet title="Save to cookbook" trigger={<Button>Save</Button>}>
  <CookbookList />
</Sheet>
<Sheet title="Filters" open={open} onOpenChange={setOpen} snapPoints={[0.5, 1]}>
  <FilterForm />
</Sheet>

Usage

Use trigger for a sheet a button opens, or open with onOpenChange to control it.

To close the sheet from an action, control it (open/onOpenChange) and set open to false in the handler, or wrap each action in SheetClose (import { SheetClose } from "@/components/ouro/sheet/sheet-parts", with your components alias): <SheetClose render={<Button>Copy link</Button>} />.

It stacks above the page at --ouro-layer, a z-index of 50 by default (from ouro-base.css). If your app has fixed bars above z-50, raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }. Don't edit the --ouro-layer: 50 line shadcn wrote; shadcn add resets it.

Testing accessibility: in WebKit, Base UI's focus guards take role="button" without a name so VoiceOver's cursor stays trapped. Exclude them: new AxeBuilder({ page }).exclude("[data-base-ui-focus-guard]").

Use instead when

  • Use Dialog instead when the content should be a centered dialog on larger screens; it renders this sheet on phones.

Props

The props the modes share. Type: SheetProps.

children (required)
Type: ReactNode
The body. It scrolls when it is taller than the sheet.
className
Type: string
Layout only, applied to the sheet panel: width and margins (max-w-lg, mx-auto). Your classes win over Ouro UI's. Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The label of the sheet's close button. The button is the last control in the sheet, hidden until it has keyboard focus, so screen-reader users on phones (who have no Escape key) can always close the sheet.
description
Type: ReactNode
A short line under the title, read out when the sheet opens.
footer
Type: ReactNode
The actions row, stacked full width on phones; primary action last.
hideTitle
Type: boolean
Hides the title visually. It still names the dialog for screen readers.
side
Type: "top" | "bottom" | "left" | "right"
Default: "bottom"
The edge the sheet slides in from and is dragged back to.
snapPoints
Type: SheetSnapPoint[]
Heights a bottom or top sheet rests at, smallest first: fractions of the viewport (0.5), px or rem. snapPoints={[0.5, 1]} opens at half height and drags up to full. Without snap points the sheet fits its content.
title (required)
Type: string
Names the sheet for screen readers and shows at its top. Always required; hide it with hideTitle.

Modes

SheetProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: SheetControlledProps

A controlled Sheet: you own open and update it in onOpenChange.

onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user dismisses the sheet (swipe, Escape, outside press).
open (required)
Type: boolean
Whether the sheet is open.

Not in this mode (typed never):

  • trigger: Not with open. Remove open and onOpenChange to let a trigger open the sheet.

With trigger: SheetWithTriggerProps

An uncontrolled Sheet: trigger opens it.

trigger (required)
Type: ReactElement
The element that opens the sheet, usually a Button: trigger={<Button>Save</Button>}.

Not in this mode (typed never):

  • onOpenChange: Not with trigger. Remove trigger to control the sheet with open and onOpenChange.
  • open: Not with trigger. Remove trigger to control the sheet with open and onOpenChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <Sheet open={open} onOpenChange={setOpen}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <Sheet open={open} onOpenChange={setOpen}>.
  • position: Not a prop. Ouro UI uses side: <Sheet side="bottom">.
  • size: Not a prop. A sheet fits its content; to set heights use snapPoints={[0.5, 1]}.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Parts

When these props cannot express your layout, build it from the parts in @/components/ouro/sheet/sheet-parts. The nesting they need follows.

import {
  SheetBackdrop,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetHandle,
  SheetPopup,
  SheetPortal,
  SheetRoot,
  SheetTitle,
  SheetTrigger,
  SheetViewport,
} from "@/components/ouro/sheet/sheet-parts";

Required nesting

<SheetRoot side="bottom">
  <SheetTrigger render={<Button>Open</Button>} />
  <SheetPortal>
    <SheetBackdrop />
    <SheetViewport>
      <SheetPopup>
        <SheetHandle />
        <SheetTitle>Filters</SheetTitle>
        <SheetContent>…</SheetContent>
      </SheetPopup>
    </SheetViewport>
  </SheetPortal>
</SheetRoot>

Each part checks that it sits inside <SheetRoot>: outside it, a part throws in development with a message that shows this JSX shape.sheet-nesting

SheetBackdrop
What it is: The dimming layer. It lightens as the sheet is dragged away. On iOS it stays anchored to the top of the screen on a scrolled page. A ref reaches the backdrop element.
SheetClose
What it is: Closes the sheet. Pass your button as render={<Button>Done</Button>}.
SheetContent
What it is: The scrollable body. A mouse can select text in it without dragging the sheet; touch still drags from anywhere once the body is scrolled to the top. While it overflows it is a tab stop, so a keyboard can scroll it.
SheetDescription
What it is: A line under the title that describes the dialog.
SheetHandle
What it is: The grab handle at the free edge of a bottom or top sheet. Decorative; renders nothing on side sheets.
SheetPopup
What it is: The sheet panel. It stretches past its edge when pulled and springs back on release.
SheetPortal
What it is: Moves the sheet to the end of <body> and makes it aware of the software keyboard (Drawer.VirtualKeyboardProvider), so focused inputs stay visible. The portal is its own stacking context at the --ouro-layer z-index (50 by default, from ouro-base.css). If your app has fixed bars above z-50, raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }. Don't edit the line shadcn wrote; shadcn add resets it.
SheetRoot
What it is: The state owner of a sheet built from parts. Use the Sheet component instead unless its props cannot express your layout.
SheetTitle
What it is: The sheet's title, which names the dialog. Every sheet needs one.
SheetTrigger
What it is: The button that opens the sheet. Pass your button as render={<Button>Open</Button>}.
SheetViewport
What it is: The full-screen layer that positions the popup against its edge. A ref reaches the viewport element.

Types

SheetSnapPoint

A snap point for a bottom or top sheet: a fraction of the viewport height (0.5), or a height in px or rem ("320px", "20rem").

type SheetSnapPoint = number | `${number}px` | `${number}rem`;