Sheet
A panel that slides in from a screen edge. A trigger opens a portalled, modal surface that traps focus and dims the page behind it — composed from a header, a scrollable body, and a pinned footer.
Basic
A Sheet.Trigger opens the Sheet.Content. Compose the trigger from a Button through render so it inherits the button's look, hover, and focus; the content holds a Sheet.Header (with a Title, Description, and a corner Close), a scrollable Sheet.Body, and a Sheet.Footer whose actions close the panel through Sheet.Close.
"use client";
import { Button, Sheet } from "@stridge/noctis";
export default function SheetBasic() {
return (
<Sheet.Root>
<Sheet.Trigger render={<Button variant="outline">Open panel</Button>} />
<Sheet.Content>
<Sheet.Header>
<Sheet.Title>Workspace settings</Sheet.Title>
<Sheet.Description>Tune how this workspace looks and behaves.</Sheet.Description>
<Sheet.CloseButton />
</Sheet.Header>
<Sheet.Body>
<p className="text-regular text-muted">
Sheets dock to a screen edge and trap focus while open. Close from the corner button, the footer, Escape,
or a click on the dimmed backdrop.
</p>
</Sheet.Body>
<Sheet.Footer>
<Sheet.Close render={<Button variant="secondary">Cancel</Button>} />
<Sheet.Close render={<Button variant="primary">Save</Button>} />
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
);
}
Sides & sizes
Dock the panel to any edge with side on Sheet.Content — end (the default), start, top, or bottom; the panel slides in from that edge and start/end flip under RTL. size sets the cross-axis extent — sm, md (default), lg, xl, or full — which reads as width for side sheets and height for top/bottom sheets.
"use client";
import { Button, Sheet } from "@stridge/noctis";
const LAYOUTS: { side: Sheet.Side; size: Sheet.Size; label: string }[] = [
{ side: "end", size: "md", label: "End · md" },
{ side: "start", size: "sm", label: "Start · sm" },
{ side: "bottom", size: "lg", label: "Bottom · lg" },
{ side: "top", size: "full", label: "Top · full" },
];
export default function SheetSidesAndSizes() {
return (
<div className="flex flex-wrap gap-3">
{LAYOUTS.map(({ side, size, label }) => (
<Sheet.Root key={label}>
<Sheet.Trigger render={<Button variant="outline">{label}</Button>} />
<Sheet.Content side={side} size={size}>
<Sheet.Header>
<Sheet.Title>{label}</Sheet.Title>
<Sheet.Description>
Docked to the {side} edge at {size} extent.
</Sheet.Description>
</Sheet.Header>
<Sheet.Body>
<p className="text-regular text-muted">
Side sheets size by width; top and bottom sheets size by height. The full extent spans the whole
cross axis.
</p>
</Sheet.Body>
<Sheet.Footer>
<Sheet.Close render={<Button variant="secondary">Close</Button>} />
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
))}
</div>
);
}
Stacked sheets
Sheets stack into layers, so a flow can drill deeper without ever leaving the panel — a list opens a detail, the detail opens an editor — and each step back peels one layer off. There are two ways to build a stack:
- Static nesting — render a
Sheet.Rootinside another sheet'sSheet.Content. Use it when the depth is fixed and known at author time. - Imperative stack — for dynamic depth (push a layer in response to a click, from anywhere), hold a manager with
useSheetStack, render it with<SheetStack manager={…}>, and drive it withpush/pop/close. Each layer is a real nested Base UI dialog, so focus trapping, scroll-lock, andEscapeordering across the stack are handled for you, and panels behind peek out by depth.
The manager is dependency-injected through <SheetStack>, so any descendant reaches it with useSheetStackContext — a button inside one layer can push the next without prop threading. A layer's content is just the inner parts (Sheet.Header / Body / Footer and a Sheet.Close); the stack supplies the surrounding Root and Content.
"use client";
import { Button, Sheet, SheetStack, useSheetStack, useSheetStackContext } from "@stridge/noctis";
/** The deeper layer, pushed on top of the list. Its content is just the inner parts — the stack wraps it. */
function MemberDetail() {
return (
<>
<Sheet.Header>
<Sheet.Title>Member detail</Sheet.Title>
<Sheet.Description>The second layer, stacked over the list.</Sheet.Description>
<Sheet.CloseButton />
</Sheet.Header>
<Sheet.Body>
<p className="text-regular text-muted">
Each layer is a real nested dialog, so focus, scroll-lock, and Escape ordering step through the stack one
level at a time. Back returns to the list; the page behind both stays inert.
</p>
</Sheet.Body>
<Sheet.Footer>
<Sheet.Close render={<Button variant="secondary">Back</Button>} />
</Sheet.Footer>
</>
);
}
/** The first layer. It reads the manager from context to push a deeper layer without threading props. */
function TeamList() {
const stack = useSheetStackContext();
return (
<>
<Sheet.Header>
<Sheet.Title>Team members</Sheet.Title>
<Sheet.Description>Drill into a member without leaving the flow.</Sheet.Description>
<Sheet.CloseButton />
</Sheet.Header>
<Sheet.Body>
<Button variant="outline" onClick={() => stack.push({ content: <MemberDetail />, size: "sm" })}>
Open member detail
</Button>
</Sheet.Body>
<Sheet.Footer>
<Button variant="secondary" onClick={() => stack.reset()}>
Close all
</Button>
</Sheet.Footer>
</>
);
}
export default function SheetStacked() {
const stack = useSheetStack();
return (
<SheetStack manager={stack}>
<Button variant="outline" onClick={() => stack.push({ content: <TeamList /> })}>
Open team panel
</Button>
</SheetStack>
);
}
The manager (SheetStackManager) exposes entries and depth for reading the stack, and push(entry) (returns the layer's key; reusing a key moves that layer to the top), pop() (hide the top layer), close(key) (hide a layer and everything above it), replace(entries) (swap the whole set), reset() (hide every layer), and remove(key) (hard-unmount, skipping the exit animation) for driving it. pop / close / reset only hide layers so they animate out; the unmount follows once the exit finishes.
Anatomy
Compose a sheet from its parts. Sheet.Root owns the open state (it accepts every Base UI Dialog.Root prop — open, defaultOpen, onOpenChange, modal). Modal by default: focus is trapped, the page is scroll-locked, and the rest of the document is inert.
Sheet.Root— owns the open state; renders no element of its own. Nest aSheetinside another sheet'sContentto stack them — panels behind peek out by depth and Base UI keeps focus and Escape ordering correct.Sheet.Trigger— opens the sheet. Style it directly or compose aButtonthroughrender.Sheet.Content— the common composition: portal, backdrop, and panel in one. Props:side(defaultend),size(defaultmd), andbackdropClassName. Reach forSheet.Portal+Sheet.Backdrop+Sheet.Popupdirectly only when you need to customize the portal or backdrop wiring.Sheet.Header— the panel's top region for theTitle,Description, and corner actions, separated from the body by a divider.Sheet.Body— the scrollable middle region; it grows to fill and scrolls its overflow so the header and footer stay put.Sheet.Footer— the panel's bottom region, pinned to the base, typically holding the primary and secondary actions.Sheet.Title+Sheet.Description— the panel's accessible name and supporting copy, linked to the popup viaaria-labelledbyandaria-describedby.Sheet.Close— closes the nearest sheet. Renders a bare button with no styling of its own, so it composes with anyButtonthroughrender— a ghost icon button for the corner dismiss, a secondary or primary button for a footer action. Give it anaria-label(or visible text) for the accessible name.
Every rendered part carries a data-slot (noctis-sheet-trigger, noctis-sheet-backdrop, noctis-sheet-viewport, noctis-sheet-popup, noctis-sheet-close, noctis-sheet-header, noctis-sheet-body, noctis-sheet-footer, noctis-sheet-title, noctis-sheet-description) for host-side styling — the popup also carries data-side and data-size. Pair it with the Base UI state attributes (data-open, data-closed, data-starting-style, data-ending-style). The popup renders through Surface at elevated elevation, so controls inside re-derive off that base and separate cleanly.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: open the sheet and move focus into the panel. |
| Tab / Shift + Tab | Move focus to the next / previous element, trapped within the open panel. |
| Esc | Close the panel and return focus to the trigger; in a stack, pop the top sheet first. |
A click on the dimmed backdrop also closes the sheet, stepping back one level in a stack. Disabled and inert content behind the panel stays unreachable while the sheet is open.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every sheet in that region retunes — e.g. .editor { --noctis-sheet-popup-peek-offset: 1.5rem; } widens how far stacked panels fan out beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI's Dialog list just the props they pass through. Expand a row for the full type and description.
Sheet.Root
No props of its own — forwards to the underlying Base UI part.