Focus Stack
Overlapping items where the selection comes forward — context stays behind.
Review queue
Drafts waiting on you
The public beta moves to April: the setup flow still drops first-time workspaces in two places, and the docs cannot fully cover for it yet.
Arrow keys or click a tab
01What it does
A queue of overlapping surfaces where the selected one lifts forward and the rest remain partially visible, so you always know where you are in the pile. Arrow keys move the selection, the stack fans back in depth order, and the content of the active item swaps without the pile ever dissolving into a plain list.
In this previewA review queue and an inbox as tactile stacks. Select with the pointer or arrow keys — the pile keeps its context behind the selection.
02Install
Registry install
Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/focus-stack/ with its stylesheet beside it.
npx shadcn@latest add <lab-origin>/r/focus-stack.jsonNo npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.
- components/lab/focus-stack/focus-stack.tsxcomponent
- components/lab/focus-stack/focus-stack.cssstyles
Machine-readable item: /r/focus-stack.json ↗
03Usage
const items = [
{ id: "r1", title: "Draft 12", meta: "Needs review", content: <Draft /> },
{ id: "r2", title: "Draft 11", meta: "Approved", content: <Draft /> },
];
<FocusStack items={items} spread={34} rotate />04Props
| Prop | Type | Default | Notes |
|---|---|---|---|
items | StackItem[] | — | { id, title, meta?, content } — content is the panel shown when selected. |
index / onIndexChange | number / (index: number) => void | — | Controlled selection. |
defaultIndex | number | 0 | Initial selection in uncontrolled use. |
spread | number | 34 | Depth offset in pixels between stacked items. |
rotate | boolean | false | A slight fan rotation for physical stacks. |
05Accessibility
- Implement as a tablist: Left/Right and Up/Down move between items, Home/End jump, the tab row is a single tab stop.
- The visible panel is labelled by its tab; panels stay in the DOM and are hidden with hidden, so screen readers get exactly one.
- Depth is decorative — no information is carried by stack position or rotation alone.
- Reduced motion removes the fan transition; the selection still changes instantly.
06Source
2 files, 384 lines. Nothing private, no configuration required.
components/lab/focus-stack/focus-stack.tsx— press enter to show or hide the source
"use client";
import {
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
import "./focus-stack.css";
export type StackItem = {
id: string;
title: string;
meta?: string;
content: ReactNode;
};
export type FocusStackProps = {
items: StackItem[];
/** Controlled selection; omit to let the stack select itself. */
index?: number;
onIndexChange?: (index: number) => void;
/** Initial selection in uncontrolled use. */
defaultIndex?: number;
/** Depth offset in pixels between stacked items. */
spread?: number;
/** A slight fan rotation for physical stacks. */
rotate?: boolean;
className?: string;
};
const SCALE_STEP = 0.045;
const MAX_ROTATION = 1.2;
const MIN_OPACITY = 0.55;
/**
* FOCUS STACK — a queue that keeps its context visible.
*
* The active card owns the flow, so the container height is the card the
* reader is looking at. The other cards sit behind it, fanned by
* distance: items before the active one lift upward, items after it drop
* downward, both scaling and fading with depth. Only the active panel is
* rendered visibly — the rest are `hidden`, so assistive technology sees
* exactly one tabpanel. Arrow keys, Home and End move selection; the tab
* row is a single tab stop.
*/
export function FocusStack({
items,
index,
onIndexChange,
defaultIndex = 0,
spread = 34,
rotate = false,
className,
}: FocusStackProps) {
const isControlled = index !== undefined;
const [internal, setInternal] = useState(defaultIndex);
const count = items.length;
const raw = isControlled ? index : internal;
const active = count === 0 ? 0 : Math.max(0, Math.min(raw, count - 1));
const uid = useId();
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
/* Uncontrolled selection follows the items when the host slices them. */
if (!isControlled && count > 0 && internal > count - 1) {
setInternal(count - 1);
}
const select = (next: number) => {
if (count === 0) return;
const clamped = Math.max(0, Math.min(next, count - 1));
if (clamped === active) return;
if (!isControlled) setInternal(clamped);
onIndexChange?.(clamped);
};
const focusAndSelect = (next: number) => {
if (count === 0) return;
const wrapped = ((next % count) + count) % count;
select(wrapped);
tabRefs.current[wrapped]?.focus();
};
const onTablistKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
focusAndSelect(active - 1);
break;
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
focusAndSelect(active + 1);
break;
case "Home":
event.preventDefault();
focusAndSelect(0);
break;
case "End":
event.preventDefault();
focusAndSelect(count - 1);
break;
default:
break;
}
};
if (count === 0) return null;
return (
<div
className={`fs-root${className ? ` ${className}` : ""}`}
data-stacked={count > 1 ? "true" : "false"}
style={{ "--fs-spread": `${spread}px` } as CSSProperties}
>
<div
className="fs-tabs"
role="tablist"
aria-orientation="horizontal"
aria-label="Stack items"
onKeyDown={onTablistKeyDown}
>
{items.map((item, i) => (
<button
key={item.id}
ref={(node) => {
tabRefs.current[i] = node;
}}
type="button"
role="tab"
id={`${uid}-tab-${i}`}
className="fs-tab"
aria-selected={i === active}
aria-controls={`${uid}-panel-${i}`}
tabIndex={i === active ? 0 : -1}
onClick={() => select(i)}
>
<span className="fs-tab-title">{item.title}</span>
{item.meta ? <span className="fs-tab-meta">{item.meta}</span> : null}
</button>
))}
</div>
<div className="fs-pile">
{items.map((item, i) => {
const depth = i - active;
const isActive = depth === 0;
const distance = Math.abs(depth);
const scale = 1 - SCALE_STEP * distance;
const backStyle: CSSProperties = {
transform: `translateY(${spread * depth}px) scale(${scale}) rotate(${
rotate ? (depth < 0 ? -MAX_ROTATION : MAX_ROTATION) : 0
}deg)`,
opacity: Math.max(MIN_OPACITY, 1 - 0.1 * distance),
zIndex: count + 1 - distance,
};
return (
<div
key={item.id}
className={`fs-card${isActive ? " fs-card--active" : ""}`}
style={isActive ? { zIndex: count + 2 } : backStyle}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
{!isActive ? (
<div
className={`fs-peek ${
depth > 0 ? "fs-peek--bottom" : "fs-peek--top"
}`}
>
<span className="fs-peek-title">{item.title}</span>
{item.meta ? (
<span className="fs-peek-meta">{item.meta}</span>
) : null}
</div>
) : null}
<div
role="tabpanel"
id={`${uid}-panel-${i}`}
aria-labelledby={`${uid}-tab-${i}`}
className="fs-panel"
tabIndex={0}
hidden={!isActive}
>
{item.content}
</div>
</div>
);
})}
</div>
</div>
);
}components/lab/focus-stack/focus-stack.css— press enter to show or hide the source
/* ============================================================
FOCUS STACK — self-contained styles.
Prefix .fs- · tokens are scoped on .fs-root with fallbacks.
============================================================ */
.fs-root {
--fs-ink: #141418;
--fs-muted: #5f5f68;
--fs-faint: #8c8c94;
--fs-line: rgba(20, 20, 24, 0.12);
--fs-line-soft: rgba(20, 20, 24, 0.07);
--fs-surface: #ffffff;
--fs-sunken: #f4f4f2;
--fs-accent: #4f27e0;
--fs-accent-soft: rgba(79, 39, 224, 0.09);
--fs-ease: cubic-bezier(0.22, 1, 0.36, 1);
--fs-duration: 320ms;
--fs-edge: var(--fs-spread, 34px);
display: block;
color: var(--fs-ink, #141418);
font-size: 0.8125rem;
line-height: 1.5;
}
/*
* When several cards are fanned, the pile reserves breathing room for
* the nearest neighbours; the rest may travel beyond it.
*/
.fs-root[data-stacked="true"] {
--fs-edge: calc(var(--fs-spread, 34px) + 8px);
}
/* ---------- tablist: title + meta, one tab stop ---------- */
.fs-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 0 0 14px;
}
.fs-tab {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
max-width: 100%;
padding: 6px 10px;
border: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.07));
border-radius: 9px;
background: var(--fs-surface, #ffffff);
color: var(--fs-muted, #5f5f68);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.fs-tab-title {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-ink, #141418);
overflow-wrap: anywhere;
}
.fs-tab-meta {
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.59375rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fs-faint, #8c8c94);
overflow-wrap: anywhere;
}
.fs-tab[aria-selected="true"] {
border-color: rgba(79, 39, 224, 0.45);
background: var(--fs-accent-soft, rgba(79, 39, 224, 0.09));
}
.fs-tab[aria-selected="false"]:hover {
background: var(--fs-sunken, #f4f4f2);
}
.fs-tab:focus-visible,
.fs-panel:focus-visible {
outline: 2px solid var(--fs-accent, #4f27e0);
outline-offset: 2px;
}
/* ---------- the pile ---------- */
.fs-pile {
position: relative;
padding: var(--fs-edge, 0px) 0;
}
.fs-card {
position: absolute;
inset: var(--fs-edge, 0px) 0;
background: var(--fs-surface, #ffffff);
border: 1px solid var(--fs-line, rgba(20, 20, 24, 0.12));
border-radius: 14px;
box-shadow: 0 1px 2px rgba(20, 20, 24, 0.05);
transition:
transform var(--fs-duration, 320ms) var(--fs-ease, cubic-bezier(0.22, 1, 0.36, 1)),
opacity var(--fs-duration, 320ms) var(--fs-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
.fs-card--active {
position: relative;
inset: auto;
}
/* Cards behind the selection keep a legible edge: the strip shows the
item they hold, so the pile reads as records in a queue rather than
blank paper. A card shifted down is seen from below, a card shifted
up from above — the strip always sits on the exposed band, and its
height tracks the spread so the label is never covered. */
.fs-peek {
position: absolute;
left: 0;
right: 0;
height: var(--fs-spread, 34px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline: 16px;
overflow: hidden;
white-space: nowrap;
}
.fs-peek--top {
top: 0;
border-bottom: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.06));
}
.fs-peek--bottom {
bottom: 0;
border-top: 1px solid var(--fs-line-soft, rgba(20, 20, 24, 0.06));
}
.fs-peek-title {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fs-ink, #141418);
overflow: hidden;
text-overflow: ellipsis;
}
.fs-peek-meta {
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.5625rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--fs-faint, #8c8c94);
white-space: nowrap;
flex: none;
}
.fs-panel {
padding: 14px 16px;
}
.fs-panel:focus-visible {
outline-offset: -2px;
border-radius: 12px;
}
@media (max-width: 30rem) {
.fs-panel {
padding: 12px 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs-card {
transition: none;
}
}