Rail
A non-modal side panel that docks to a layout edge and reflows the content beside it. Unlike a sheet, the page stays visible and usable while the rail is open — no backdrop, no focus trap, no Escape-to-close.
Basic
A Rail.Trigger toggles the panel open. The Rail.Layout is the surrounding region — its Rail.Content is the main area and its Rail.Panel is the rail, composed from a Rail.Header (with a Rail.Title and a corner Rail.Close) and a Rail.Body. Compose the trigger from a Button through render so it inherits the button's look.
"use client";
import { Button, Icon, Rail } from "@stridge/noctis";
import { PanelRight, X } from "lucide-react";
export default function RailBasic() {
return (
<Rail.Root>
<Rail.Layout
variant="squeeze"
size="xs"
className="h-40 overflow-hidden rounded-lg border border-border bg-background"
>
<Rail.Content className="flex items-start gap-3 p-4">
<Rail.Trigger render={<Button variant="outline" size="sm" startIcon={PanelRight} />}>Inspector</Rail.Trigger>
<div className="flex flex-1 flex-col gap-2" aria-hidden>
<div className="h-2 w-full rounded-sm bg-control" />
<div className="h-2 w-3/4 rounded-sm bg-control" />
<div className="h-2 w-1/2 rounded-sm bg-control" />
</div>
</Rail.Content>
<Rail.Panel>
<Rail.Header className="py-3">
<Rail.Title>Inspector</Rail.Title>
<Rail.Close aria-label="Close" className="size-7">
<Icon icon={X} size="sm" />
</Rail.Close>
</Rail.Header>
<Rail.Body className="text-small text-muted">Properties of the current selection.</Rail.Body>
</Rail.Panel>
</Rail.Layout>
</Rail.Root>
);
}
Reflow
variant on Rail.Layout sets how the content yields to the panel: squeeze shrinks the content beside the rail, while push keeps its width and slides it under. Either way the content stays visible and usable — that's the line between a rail and a sheet.
"use client";
import { Button, Icon, Rail } from "@stridge/noctis";
import { X } from "lucide-react";
import type { CSSProperties } from "react";
const NARROW_PANEL = { "--noctis-rail-panel-width": "12rem" } as CSSProperties;
const NARROW_SLIDE = { "--rail-w": "12rem" } as CSSProperties;
const VARIANTS = [
{ variant: "squeeze", label: "Squeeze" },
{ variant: "push", label: "Push" },
] as const;
export default function RailReflow() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{VARIANTS.map(({ variant, label }) => (
<div key={variant} className="flex flex-col gap-1.5">
<span className="font-mono text-mini tracking-wide text-subtle uppercase">{label}</span>
<Rail.Root style={NARROW_PANEL}>
<Rail.Layout
variant={variant}
size="xs"
style={NARROW_SLIDE}
className="h-28 overflow-hidden rounded-lg border border-border bg-background"
>
<Rail.Content className="flex items-center gap-3 p-3">
<Rail.Trigger render={<Button variant="outline" size="sm" />}>Toggle</Rail.Trigger>
<div className="flex flex-1 flex-col gap-1.5" aria-hidden>
<div className="h-1.5 w-full rounded-sm bg-control" />
<div className="h-1.5 w-3/4 rounded-sm bg-control" />
</div>
</Rail.Content>
<Rail.Panel>
<Rail.Header className="py-2.5">
<Rail.Title className="text-small">Inspector</Rail.Title>
<Rail.Close aria-label="Close" className="size-6">
<Icon icon={X} size="xs" />
</Rail.Close>
</Rail.Header>
</Rail.Panel>
</Rail.Layout>
</Rail.Root>
</div>
))}
</div>
);
}
Anatomy
Compose a rail from its parts. Rail.Root owns the open state (controlled via open / onOpenChange, or uncontrolled via defaultOpen) on Base UI's Collapsible and renders transparently (display: contents), so the Layout becomes the surrounding layout's child directly.
Rail.Root— owns the open state; renders no box of its own.Rail.Trigger— toggles the panel. Style it directly or compose aButtonthroughrender.Rail.Layout— the region that reflows. Props:variant(squeeze|push),side(start|end, RTL-aware), andsize(xs|sm|md|lg).Rail.Content— the main area beside the rail.Rail.Panel— the rail surface itself; takes its ownside/sizewhen used standalone.Rail.Header+Rail.Title— the panel's top region and its accessible name.Rail.Body— the panel's scrollable content region.Rail.Close— closes the rail; give it anaria-labelfor the accessible name.
Every rendered part carries a data-slot (noctis-rail, noctis-rail-trigger, noctis-rail-layout, noctis-rail-content, noctis-rail-panel, noctis-rail-surface, noctis-rail-close, noctis-rail-header, noctis-rail-body, noctis-rail-title) for host-side styling — pair it with the rail state attributes (data-variant, data-side, data-size, data-docking) and the Base UI Collapsible state (data-open, data-closed).
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: toggle the rail open or closed. |
| Tab / Shift + Tab | Move through the page and into the open panel — focus is not trapped, since the rail is non-modal. |
The rail is non-modal by design: there's no backdrop, no focus trap, and no Escape-to-close, so the surrounding content stays fully reachable while it's 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 rail in that region retunes — e.g. .editor { --noctis-rail-panel-width: 20rem; } widens every rail 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 forward to Base UI's Collapsible list just the props they pass through. Expand a row for the full type and description.