Rail Stage
A composed content browser that pairs a scalable vertical rail with one focused stage, collapsing to a scrollable strip on small screens.
app-patternpanel-swap
Northstar / Spring 2026
Creative direction
Approved
Creative direction 01
Make room for the signal.
A launch system built around generous space, decisive type, and one unmistakable focal point.
- Deliverables
- 24 / 28
- Confidence
- 92%
Northstar systemsEdition 04
N.
Quiet systems for ambitious teams.
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import * as React from "react";
import { cn } from "@/lib/utils";
export type RailStageItem = {
/** Stable identity; also the tab's value. */
id: string;
/** Rail entry. A node rather than a string so entries can carry an icon. */
label: React.ReactNode;
/** What the stage shows while this entry is selected. */
content: React.ReactNode;
/**
* Optional strip pinned above the stage — a title, badges, a link out. Kept
* per item so it can describe whatever is currently on stage.
*/
header?: React.ReactNode;
};
export type RailStageProps = Omit<
React.ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
items: RailStageItem[];
/** Controlled selection. Pair with `onValueChange`. */
value?: string;
/** Initial selection when uncontrolled. Defaults to the first item. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Which side the rail sits on once there is room for it. Defaults to "end". */
railSide?: "start" | "end";
/** Accessible name for the rail, which is a tab list. */
railLabel?: string;
/** Rail width once it sits beside the stage. Defaults to 220px. */
railWidth?: number | string;
/**
* Arrow-key axis. Defaults to "vertical" to match the rail's wide layout; set
* "horizontal" if you expect the collapsed strip to be the common case.
*/
orientation?: "vertical" | "horizontal";
railClassName?: string;
tabClassName?: string;
indicatorClassName?: string;
stageClassName?: string;
headerClassName?: string;
};
/**
* A rail of choices beside a single stage: pick an entry, the stage shows it.
*
* Why this exists: a gallery of live examples wants one large surface, not a grid
* of small ones — every small tile competes for the same attention and none of
* them reads. Tabs solve that, except a stock horizontal strip runs out of room
* past a handful of entries. A vertical rail scales to a dozen and still reads as
* an index of what else there is.
*
* Built on Base UI's Tabs, so arrow-key navigation, roving focus, and the
* `tablist` / `tab` / `tabpanel` wiring are the primitive's job, not ours.
*
* @example
* <RailStage
* railLabel="Examples"
* items={[
* { id: "chart", label: "Chart", header: <h3>Chart</h3>, content: <Chart /> },
* { id: "table", label: "Table", content: <Table /> },
* ]}
* />
*
* Notes:
* - Below `sm` the rail becomes a horizontally scrollable strip above the stage,
* because a vertical rail plus a stage does not fit a phone. The selected entry
* is scrolled into view on change, so a selection made with the keyboard never
* lands off-screen. `orientation` stays whatever you set it to — the arrow-key
* axis cannot follow a media query without shipping one.
* - The stage is `min-h-0 min-w-0` and clips, so content that animates its own
* size cannot stretch the shell and shift the page around it.
* - Layout only: beyond a border and a divider the stage brings no background or
* backdrop of its own. Style it through `stageClassName`.
*/
export function RailStage({
items,
value,
defaultValue,
onValueChange,
railSide = "end",
railLabel,
railWidth = 220,
orientation = "vertical",
className,
style,
railClassName,
tabClassName,
indicatorClassName,
stageClassName,
headerClassName,
...props
}: RailStageProps) {
const railRef = React.useRef<HTMLDivElement | null>(null);
const indicatorRef = React.useRef<HTMLSpanElement | null>(null);
const firstId = items[0]?.id;
const [uncontrolledValue, setUncontrolledValue] = React.useState(
() => defaultValue ?? firstId,
);
const isControlled = value !== undefined;
const activeId = isControlled ? value : uncontrolledValue;
const activeItem = items.find((item) => item.id === activeId) ?? items[0];
const handleValueChange = React.useCallback(
(next: unknown) => {
const nextId = String(next);
if (!isControlled) {
setUncontrolledValue(nextId);
}
onValueChange?.(nextId);
},
[isControlled, onValueChange],
);
const syncIndicator = React.useCallback(() => {
const rail = railRef.current;
const indicator = indicatorRef.current;
const selected = rail?.querySelector<HTMLElement>(
"[data-rail-stage-active]",
);
if (!rail || !indicator || !selected) return;
const indicatorHeight = Math.max(selected.offsetHeight - 16, 0);
const indicatorY =
selected.offsetTop + (selected.offsetHeight - indicatorHeight) / 2;
indicator.style.height = `${indicatorHeight}px`;
indicator.style.transform = `translate3d(0, ${indicatorY}px, 0)`;
indicator.dataset.active = "";
}, []);
React.useLayoutEffect(() => {
const rail = railRef.current;
const selected = rail?.querySelector<HTMLElement>(
"[data-rail-stage-active]",
);
syncIndicator();
if (!rail || !selected || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(syncIndicator);
observer.observe(rail);
observer.observe(selected);
return () => observer.disconnect();
}, [activeItem?.id, syncIndicator]);
// Only meaningful in the collapsed strip: keep the selected entry visible when
// selection moves by keyboard or from outside the component.
React.useEffect(() => {
const rail = railRef.current;
if (!rail || rail.scrollWidth <= rail.clientWidth) return;
const selected = rail.querySelector<HTMLElement>(
"[data-rail-stage-active]",
);
if (!selected) return;
rail.scrollTo({
left: Math.max(selected.offsetLeft - 16, 0),
behavior: "auto",
});
}, [activeItem?.id]);
if (!activeItem) {
return null;
}
return (
<TabsPrimitive.Root
{...props}
data-slot="rail-stage"
orientation={orientation}
value={activeItem.id}
onValueChange={handleValueChange}
style={{
...style,
["--rail-stage-rail" as string]:
typeof railWidth === "number" ? `${railWidth}px` : railWidth,
}}
className={cn(
"grid min-w-0 overflow-hidden rounded-lg border bg-card text-card-foreground",
railSide === "start"
? "sm:grid-cols-[var(--rail-stage-rail)_minmax(0,1fr)]"
: "sm:grid-cols-[minmax(0,1fr)_var(--rail-stage-rail)]",
className,
)}
>
<div
className={cn(
"order-2 flex min-w-0 flex-col",
railSide === "start" ? "sm:order-2" : "sm:order-1",
)}
>
{activeItem.header ? (
<div
className={cn(
"min-w-0 border-b bg-muted/20 px-4 py-3",
headerClassName,
)}
>
{activeItem.header}
</div>
) : null}
{items.map((item) => (
<TabsPrimitive.Panel
key={item.id}
value={item.id}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 items-center justify-center overflow-hidden p-4 outline-none sm:p-6",
stageClassName,
)}
>
{item.content}
</TabsPrimitive.Panel>
))}
</div>
<TabsPrimitive.List
ref={railRef}
aria-label={railLabel}
className={cn(
"relative order-1 flex min-w-0 flex-nowrap overflow-x-auto overflow-y-hidden border-b",
// Hide the strip's scrollbar without depending on a project utility.
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"sm:flex-col sm:overflow-visible sm:border-b-0",
railSide === "start"
? "sm:order-1 sm:border-r"
: "sm:order-2 sm:border-l",
railClassName,
)}
>
<span
ref={indicatorRef}
aria-hidden="true"
data-slot="rail-stage-indicator"
className={cn(
"pointer-events-none absolute top-0 hidden w-px bg-foreground opacity-0 will-change-transform",
"transition-[transform,opacity] duration-200 ease-[cubic-bezier(0.645,0.045,0.355,1)]",
"data-active:opacity-100 motion-reduce:transition-none sm:block",
railSide === "start" ? "left-0" : "right-0",
indicatorClassName,
)}
/>
{items.map((item) => (
<TabsPrimitive.Tab
key={item.id}
value={item.id}
data-rail-stage-active={
item.id === activeItem.id ? "" : undefined
}
className={cn(
"relative -mb-px flex h-11 min-w-44 flex-none items-center gap-2 border-b px-4 text-left font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground transition-colors",
"hover:bg-muted/30 hover:text-foreground",
"focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
// The collapsed strip keeps a local underline; wide layouts use
// the single measured indicator above so it can slide between tabs.
"data-active:bg-muted/35 data-active:text-foreground",
"data-active:after:absolute data-active:after:inset-x-4 data-active:after:bottom-0 data-active:after:h-px data-active:after:bg-foreground",
"sm:min-w-0 sm:data-active:after:hidden",
tabClassName,
)}
>
<span className="min-w-0 truncate">{item.label}</span>
</TabsPrimitive.Tab>
))}
</TabsPrimitive.List>
</TabsPrimitive.Root>
);
}