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 } from "@stridge/noctis/button";
import { Sheet } from "@stridge/noctis/sheet";
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 } from "@stridge/noctis/button";
import { Sheet } from "@stridge/noctis/sheet";
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 } from "@stridge/noctis/button";
import { Sheet, SheetStack, useSheetStack, useSheetStackContext } from "@stridge/noctis/sheet";
/** 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.
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below, then open the sheet: the panel re-tunes too, even though it portals to <body>, because the scope rides a portal-safe context. See Customization for nesting, reset, and the raw-token portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Select } from "@stridge/noctis/select";
import { Sheet } from "@stridge/noctis/sheet";
import { useState } from "react";
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground for a floating component: the three seed knobs drive a scope wrapping
* the sheet. Open it and the panel re-tunes too — corners, spacing, and text size — even though it
* portals out to `<body>`, because the scope rides a portal-safe context, not just the DOM cascade.
*/
export default function SheetThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<Sheet.Root>
<Sheet.Trigger render={<Button variant="secondary">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">The panel re-tunes with the scope above.</p>
</Sheet.Body>
<Sheet.Footer>
<Sheet.Close render={<Button variant="primary">Save</Button>} />
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
</ThemeScope>
</div>
</div>
);
}
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.