Drill-down navigation where every pushed level compresses into a slim clickable book spine instead of vanishing behind a back button, so the whole navigation history stands on a shelf beside you.
npx shadcn add https://design.helpmarq.com /r/drill-down-spines.jsonregistry/core/drill-down-spines/component.tsx"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// SpineStack — drill-down navigation where a pushed level never vanishes
// behind a back button: it compresses against the left edge into a slim
// clickable book spine, so the whole navigation stack stands on a shelf
// beside the current page, labeled and directly tappable at any depth.
//
// Each level's width is an explicit, always-computed pixel value (never
// flex:auto), tracked against the container's measured width via
// ResizeObserver. That is what lets a level's width transition smoothly in
// EITHER direction on a single CSS `transition: width` — full page shrinking
// to a 14px spine on push, or a 14/22px spine growing back to full width on
// pop — both are just px-to-px interpolation, no measure-then-override FLIP
// hack required. The transition runs on a spring-approximating overshoot
// cubic-bezier (not a hand-rolled rAF integrator): honest about the
// mechanism, still reads as a little bounce settling into place.
//
// A spine's content crossfades between a vertical Geist Mono title (the
// resting 14px look) and a horizontal truncated one, widened to 22px, either
// once the shelf is wide enough (a container breakpoint) or transiently on
// hover/focus of that one spine — rotated text is genuinely harder to read,
// so wide-enough shelves and any focused/hovered spine get the easier
// horizontal form. A 1px inset "lit" edge (color-mix over --foreground, a
// decoration, never --accent) reads as the spine's physical edge catching
// light, so the row reads as stacked thickness even at rest.
//
// Clicking any spine pops every level above it in one motion: the levels
// above cascade off in reverse-push order (the most recently pushed leaves
// first, each subsequent one staggered ~90ms behind) while the clicked
// spine's width transitions back up to full — "re-inflating" in parallel
// with, not after, the cascade above it. `prefers-reduced-motion` drops the
// stagger and the transition entirely: pops commit their new stack
// synchronously, entrances render at final width immediately.
//
// A11y: every spine is a real <button> whose accessible name is the level's
// full title (`aria-label`; the rotated/truncated visual text is
// `aria-hidden`, decorative only) — full horizontal names regardless of how
// little of the title is legible on screen. All spines sit inside a `<nav>`
// landmark labeled "Navigation history, N levels". Focus moves to the new
// active page's heading (a `tabIndex={-1}` <h2>) on every push and on every
// pop's commit, so the SPA-style navigation announces itself to assistive
// tech the same way a full route change would. Zero dependencies, DOM+CSS
// only — no canvas.
// ---------------------------------------------------------------------------
export interface SpineStackLevel {
/** stable id — also used as the React key and as the pop target */
id: string;
/** shown as the vertical/horizontal spine label and the active page heading */
title: string;
content: ReactNode;
}
export interface SpineStackHandle {
/** push a new level onto the stack; ignored if `id` already exists in the stack or a pop is mid-animation */
push: (level: SpineStackLevel) => void;
/** pop every level above `id`; no-op if `id` isn't in the stack or is already the active (top) level */
popTo: (id: string) => void;
/** pop exactly one level (convenience for a "back" affordance); no-op at the root */
pop: () => void;
/** replace the whole stack with a single level, instantly, no animation */
reset: (level: SpineStackLevel) => void;
}
export interface SpineStackProps {
/** seed level for the stack; only read on mount — the stack is uncontrolled thereafter, drive it via the ref */
initial: SpineStackLevel;
/** fires after the top (active) level settles — on mount, on push, and once a pop's cascade commits */
onNavigate?: (id: string) => void;
className?: string;
}
const SPINE_NARROW = 14;
const SPINE_WIDE = 22;
const WIDE_BREAKPOINT = 640;
const TRANSITION_MS = 420;
const STAGGER_MS = 90;
const ENTER_MS = 380;
const SPRING_EASE = "cubic-bezier(0.34, 1.56, 0.64, 1)";
const MIN_ACTIVE_WIDTH = 200;
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
export const SpineStack = forwardRef<SpineStackHandle, SpineStackProps>(function SpineStack(
{ initial, onNavigate, className = "" },
ref
) {
const [stack, setStack] = useState<SpineStackLevel[]>(() => [initial]);
const [collapsingIds, setCollapsingIds] = useState<string[]>([]); // newest-first
const [poppingTarget, setPoppingTarget] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [reducedMotion, setReducedMotion] = useState(false);
const [enteringId, setEnteringId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const isAnimatingRef = useRef(false);
const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const enterTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRenderRef = useRef(true);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReducedMotion(mq.matches);
const onChange = () => setReducedMotion(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setContainerWidth(el.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) setContainerWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(
() => () => {
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
},
[]
);
const activeId = poppingTarget ?? stack[stack.length - 1].id;
// focus + onNavigate fire once the *committed* top settles (immediately on
// push, or once a pop's cascade actually truncates the stack) — never
// mid-cascade, and never on first mount.
const committedActiveId = stack[stack.length - 1].id;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return;
}
if (poppingTarget) return; // still mid-cascade; effect re-fires once it clears
headingRef.current?.focus({ preventScroll: true });
onNavigate?.(committedActiveId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- onNavigate intentionally excluded, fires on id change only
}, [committedActiveId, poppingTarget]);
const push = useCallback(
(level: SpineStackLevel) => {
if (isAnimatingRef.current) return;
setStack((prev) => {
if (prev.some((l) => l.id === level.id)) return prev;
return [...prev, level];
});
if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
if (!reducedMotion) {
setEnteringId(level.id);
enterTimeoutRef.current = setTimeout(() => setEnteringId(null), ENTER_MS);
}
},
[reducedMotion]
);
const popTo = useCallback(
(id: string) => {
if (isAnimatingRef.current) return;
const idx = stack.findIndex((l) => l.id === id);
if (idx === -1 || idx === stack.length - 1) return;
const above = stack
.slice(idx + 1)
.map((l) => l.id)
.reverse(); // newest (most recently pushed) first
if (reducedMotion) {
setStack((prev) => prev.slice(0, idx + 1));
return;
}
isAnimatingRef.current = true;
setPoppingTarget(id);
setCollapsingIds(above);
const total = (above.length - 1) * STAGGER_MS + TRANSITION_MS + 40;
commitTimeoutRef.current = setTimeout(() => {
setStack((prev) => prev.slice(0, idx + 1));
setCollapsingIds([]);
setPoppingTarget(null);
isAnimatingRef.current = false;
}, total);
},
[stack, reducedMotion]
);
const pop = useCallback(() => {
if (stack.length < 2) return;
popTo(stack[stack.length - 2].id);
}, [stack, popTo]);
const reset = useCallback((level: SpineStackLevel) => {
if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
isAnimatingRef.current = false;
setCollapsingIds([]);
setPoppingTarget(null);
setEnteringId(null);
setStack([level]);
}, []);
useImperativeHandle(ref, () => ({ push, popTo, pop, reset }), [push, popTo, pop, reset]);
const wide = containerWidth >= WIDE_BREAKPOINT;
const spineWidthFor = (id: string) => {
if (collapsingIds.includes(id)) return 0;
return wide || hoveredId === id || focusedId === id ? SPINE_WIDE : SPINE_NARROW;
};
const spinesTotal = stack.reduce((sum, l) => sum + (l.id === activeId ? 0 : spineWidthFor(l.id)), 0);
const GAP = 3;
const gapCount = Math.max(0, stack.length - 1);
const activeWidth = containerWidth
? clamp(containerWidth - spinesTotal - gapCount * GAP, MIN_ACTIVE_WIDTH, containerWidth)
: undefined;
const navLabel = `Navigation history, ${stack.length} level${stack.length === 1 ? "" : "s"}`;
return (
<div
ref={containerRef}
className={["ns-drill-down-spines relative flex h-full w-full overflow-hidden", className]
.filter(Boolean)
.join(" ")}
>
<style>{`
.ns-drill-down-spines .ns-level{transition:width ${TRANSITION_MS}ms ${SPRING_EASE},opacity ${TRANSITION_MS}ms ease-out;}
.ns-drill-down-spines .ns-spine-face,
.ns-drill-down-spines .ns-active-face{transition:opacity ${TRANSITION_MS}ms ease-out;}
.ns-drill-down-spines .ns-spine-label{transition:opacity 200ms ease-out;}
.ns-drill-down-spines .ns-spine-enter{animation:ns-spine-enter ${ENTER_MS}ms ${SPRING_EASE};}
@keyframes ns-spine-enter{
from{opacity:0.35;transform:translateX(18px);}
to{opacity:1;transform:translateX(0);}
}
@media (prefers-reduced-motion: reduce){
.ns-drill-down-spines .ns-level,
.ns-drill-down-spines .ns-spine-face,
.ns-drill-down-spines .ns-active-face,
.ns-drill-down-spines .ns-spine-label{transition:none !important;}
.ns-drill-down-spines .ns-spine-enter{animation:none !important;}
}
`}</style>
<nav aria-label={navLabel} className="flex h-full w-full" style={{ gap: GAP }}>
{stack.map((level) => {
const isActive = level.id === activeId;
const collapsing = !isActive && collapsingIds.includes(level.id);
const collapseIndex = collapsingIds.indexOf(level.id);
const width = isActive ? activeWidth : spineWidthFor(level.id);
const isWideSpine = !isActive && width === SPINE_WIDE;
return (
<div
key={level.id}
style={{
width,
minWidth: isActive ? MIN_ACTIVE_WIDTH : width,
opacity: collapsing ? 0 : 1,
transitionDelay: collapsing ? `${collapseIndex * STAGGER_MS}ms` : "0ms",
}}
className={[
"ns-level relative h-full shrink-0 overflow-hidden rounded-md border border-border bg-surface",
enteringId === level.id ? "ns-spine-enter" : "",
]
.filter(Boolean)
.join(" ")}
>
{/* spine face — the level's resting/collapsed representation. Stays mounted
even while the active face is showing, so the width transition (on the
shared wrapper above) and this opacity crossfade run on ONE persistent
element, not a remount between two different DOM nodes. */}
<button
type="button"
data-slot="spine"
aria-label={level.title}
title={level.title}
inert={isActive || undefined}
onClick={() => popTo(level.id)}
onMouseEnter={() => setHoveredId(level.id)}
onMouseLeave={() => setHoveredId((h) => (h === level.id ? null : h))}
onFocus={() => setFocusedId(level.id)}
onBlur={() => setFocusedId((f) => (f === level.id ? null : f))}
style={{
opacity: isActive ? 0 : 1,
boxShadow: "inset -1px 0 0 0 color-mix(in srgb, var(--foreground) 22%, transparent)",
}}
className="ns-spine-face absolute inset-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<span
aria-hidden
className="ns-spine-label pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden whitespace-nowrap px-1 font-mono text-[10px] tracking-wide text-muted"
style={{
writingMode: "vertical-rl",
textOverflow: "ellipsis",
opacity: isWideSpine ? 0 : 1,
}}
>
{level.title}
</span>
<span
aria-hidden
className="ns-spine-label pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-nowrap px-1.5 font-mono text-[10px] tracking-wide text-ellipsis text-muted"
style={{ opacity: isWideSpine ? 1 : 0 }}
>
{level.title}
</span>
</button>
{/* active face — the level's full page. Mounted for every level, always,
so a spine popping back to active re-inflates in place instead of
swapping to a freshly-mounted pane. */}
<div
inert={isActive ? undefined : true}
style={{ opacity: isActive ? 1 : 0, pointerEvents: isActive ? undefined : "none" }}
className="ns-active-face absolute inset-0 overflow-auto bg-background"
>
<div className="flex h-full flex-col p-4">
<h2
ref={isActive ? headingRef : undefined}
tabIndex={-1}
className="mb-3 text-base font-semibold tracking-tight text-foreground outline-none"
>
{level.title}
</h2>
<div className="min-h-0 flex-1">{level.content}</div>
</div>
</div>
</div>
);
})}
</nav>
</div>
);
});
Build a drill-down navigation primitive for master-detail and multi-level record hierarchies (folders, nested tickets, org charts) where pushing a new level does not hide the previous one behind a back button: the outgoing page compresses in place into a slim clickable spine that stays permanently visible on a shelf to the left of whatever is currently active, at any depth. Manage the stack uncontrolled, driven imperatively via a ref handle: `push({ id, title, content })` appends a new active level (no-op if the id already exists in the stack, or if a pop is mid-animation), `popTo(id)` collapses every level above `id` back to it, `pop()` is a one-level-back convenience, `reset(level)` replaces the whole stack instantly with no animation. Critically, every level in the stack is ONE persistent wrapper element for its entire lifetime — mounted once when pushed, unmounted only when actually popped past — never two different elements swapped between an 'active' tree position and a 'spine' tree position. Inside that wrapper are two always-mounted, absolutely-positioned faces: a spine-face `<button>` (vertical/horizontal title, click-to-popTo) and an active-face `<div>` (heading + content), cross-fading via opacity, with `inert` applied to whichever face is currently not the front one so its buttons/inputs are unreachable and hidden from the accessibility tree without unmounting it. The active-face additionally gets `pointer-events: none` while inert (not just `inert` alone) — `inert` removes an element from the accessibility tree but `document.elementFromPoint`/real hit-testing still resolves to whatever is topmost in paint order, so without an explicit `pointer-events: none` the invisible (opacity 0) active-face sitting on top would swallow clicks meant for the spine-face button beneath it. Because the wrapper never remounts, the wrapper's own width — an explicit pixel value every render, computed from a ResizeObserver-measured container width minus the sum of every other level's current spine width (14px resting, 22px widened) and inter-item gaps, never `flex:auto` — transitions cleanly on a single `transition: width` in EITHER direction on that same node: springing down from full to 14px as a level is pushed past, or re-inflating from 14/22px back to full as a popTo target becomes active again. The timing function is a spring-approximating overshoot cubic-bezier (`cubic-bezier(0.34, 1.56, 0.64, 1)`), not a hand-rolled physics integrator — an honest choice for a DOM/CSS-only nav primitive, and it still reads as a small bounce settling into place. Pushing a level: the previously-active wrapper's width springs down to spine width while its active-face fades out and its spine-face fades in — a horizontal heading crossfading to a `writing-mode: vertical-rl` Geist Mono title in `--muted` (two absolutely-positioned spans within the spine-face, both `aria-hidden` since the button's real accessible name is a plain aria-label carrying the full title); the freshly pushed level is a brand-new wrapper, mounted already at its resolved active width, playing a short slide-and-fade-in keyframe from the right (`translateX(18px)` -> `translateX(0)`) since there is no prior state for a new element to interpolate from. Each spine-face carries a 1px inset right edge via `box-shadow: inset -1px 0 0 0 color-mix(in srgb, var(--foreground) 22%, transparent)` — a decorative lit edge (never `--accent`, which appears only on focus rings) so the row reads as physical stacked thickness even at rest. Clicking any spine calls `popTo` on it: every level above cascades off in reverse-push order on its own still-mounted wrapper (the most recently pushed level's width and opacity go to 0 first, each earlier one staggered 90ms further behind via `transition-delay`) while the clicked level's own wrapper simultaneously transitions its width back up to full and crossfades its active-face back in — the re-inflation runs in parallel with the cascade above it, not after, because it is the same element animating, not a fresh mount. A11y: spine-faces are real `<button>`s, all levels sit inside one `<nav aria-label="Navigation history, N levels">` landmark (N = total stack depth including the active page — the active page's content living inside the same landmark as the history is an accepted minor semantic tradeoff for keeping every level a single animatable element); the rotated/truncated visual label is decorative only, the accessible name is always the full title via `aria-label` plus a native `title` tooltip. Above a ~640px container-width breakpoint every spine permanently widens to 22px with a horizontal truncated label instead of the rotated 14px one (rotated text is genuinely harder to read); below that breakpoint, hovering or focusing one individual spine widens just that spine to 22px with the same horizontal label, reverting once hover/focus leaves. Focus moves to the newly active page's heading (a `tabIndex={-1}` `<h2>`, one per level, each inert while its level isn't active) on every push and once every pop's cascade actually commits (never mid-cascade) — the same SPA-route-change focus pattern a full page navigation would use, and `onNavigate(id)` fires at that same moment. `prefers-reduced-motion` drops every transition, the stagger, and the entrance keyframe: pops commit their new stack synchronously and every level renders at its resolved final width immediately, fully usable and legible, just not eased into. Zero dependencies, DOM+CSS only — no canvas. Differs from toast-gravity-stack, which accumulates dropped toast data as horizontal strata piling under gravity (a core sample of events that arrived, read once and dismissed): drill-down-spines accumulates navigation depth as vertical spines that are each a live, permanently clickable route target back into the hierarchy — a bookshelf you can reach into at any depth, not a core sample you only ever read from the top.