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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/sheetIts 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.13import { 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
Dialoginstead 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),pxorrem.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
falsewhen 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 withopen. RemoveopenandonOpenChangeto 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 withtrigger. Removetriggerto control the sheet withopenandonOpenChange.open: Not withtrigger. Removetriggerto control the sheet withopenandonOpenChange.
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 usesopenwithonOpenChange:<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 usesside:<Sheet side="bottom">.size: Not a prop. A sheet fits its content; to set heights usesnapPoints={[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
refreaches 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-layerz-index (50 by default, from ouro-base.css). If your app has fixed bars above z-50, raise it in a:rootrule of your own after the Ouro tokens, outside any@layer::root { --ouro-layer: 60; }. Don't edit the line shadcn wrote;shadcn addresets it. SheetRoot- What it is: The state owner of a sheet built from parts. Use the
Sheetcomponent 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
refreaches 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`;