Skip to content
Join UI
Data DisplaynewAdded Aug 3, 2026

Focus Stack

A coil of pills that keeps one item in focus and lets the rest twist away, turned either by a timer or by the page scroll.

View sourceon GitHub (opens in a new tab)Open full preview

FocusStack draws a list as a receding stack: rank by rank the pills shrink, fade, blur and lean away from the middle, and the one in focus straightens out, comes back to full size and lifts off the page. Every one of those properties is a function of a single fractional number — the distance from a pill to the focused position — so nothing snaps between states and two pills can sit half-focused either side of the centre mid-turn. That number has two drivers, and they are the two variants. In auto mode a spring walks it along on a timer, the stack wraps end to end, and a pill can be clicked or arrowed into focus; the timer stops for a pointer, for a caret, for a stack scrolled out of view and for anyone who has asked for reduced motion. In scroll mode the number is mapped to the stack's own transit through the viewport instead, so the coil turns exactly as far as the reader scrolls it and nothing moves on its own.

Preview

Focus Stack
mode="auto"UI Architecture

Advances on a timer, and stops the moment a pointer, a caret or a reduced-motion setting says so. Click a pill or arrow into it to take the turn yourself.

mode="scroll"Asset Library

The same drawing, with the focus handed to the page. Scroll this preview through the viewport and the stack turns with it — no timer, and nothing moves that the reader did not move.

  1. Asset LibraryEvery mark, icon and photograph in one place.
  2. Design LanguageThe rules the whole product is written in.
  3. Art DirectionHow a page is meant to feel before it is read.
  4. UI ArchitectureLayout, hierarchy and the shape of a screen.
  5. Visual ModulesBlocks that assemble into a page in any order.
  6. Motion SystemDuration, easing and what earns an animation.
  7. Brand FrameworkThe voice everything above has to speak in.
Labels only, tighter coil

No accents and no second line, with tilt and depth pulled in — a quieter stack for a sidebar or a narrow column.

Installation

Install Focus Stack with the shadcn CLI. The registry item resolves its own dependencies and writes the file to components/joinui/focus-stack.tsx.

pnpm dlx shadcn@latest add @joinui/focus-stack

Usage

example.tsx
import { FocusStack } from "@/components/joinui/focus-stack"
import { Compass, Layers, Shapes, Sparkles } from "lucide-react"

export function Example() {
  return (
    <FocusStack
      label="Design system"
      items={[
        { id: "language", label: "Design Language", icon: <Compass />, accent: "#6366f1" },
        { id: "direction", label: "Art Direction", icon: <Shapes />, accent: "#f97316" },
        { id: "architecture", label: "UI Architecture", icon: <Layers />, accent: "#a855f7" },
        { id: "motion", label: "Motion System", icon: <Sparkles />, accent: "#ef4444" },
      ]}
    />
  )
}

Props

FocusStack

Props for FocusStack
PropTypeDefaultDescription
items (required)FocusStackItem[]The pills to render, in order.
mode"auto" | "scroll""auto"Auto advances on a timer and takes clicks and arrow keys. Scroll hands the focus to the page, so the coil turns as the stack passes through the viewport.
indexnumberControlled focus. Leave unset to let the component hold it, and read the position through onIndexChange instead.
defaultIndexnumber0Where an uncontrolled stack starts.
onIndexChange(index: number) => voidFires with the focused index in both modes — in scroll mode once per pill rather than once per frame.
intervalnumber2600Milliseconds between advances in auto mode. Floored at 600.
loopbooleanWrap past the ends. Defaults to true in auto mode and false in scroll mode. Looping puts the tail of the list above the head, so the depth comes down to fit a short list rather than showing a pill twice.
pausedbooleanfalseExternally held pause, for a play control of your own. Autoplay also pauses on hover, on focus and off screen without it.
depthnumber3Ranks drawn either side of the focused pill. It also sets the height of the frame, which the component reserves up front so nothing below it shifts.
size"sm" | "md" | "lg""md"Pill, badge, type and lane scale.
tiltnumber5Degrees of lean per rank. Both ends twist the same way, so the focused pill is the only level one. Negative twists the coil the other way; 0 stacks it flat.
blurnumber1.1Pixels of blur per rank. Set 0 to drop the filter — and the per-frame repaint with it — on a long list or a weak device.
interactivebooleanWhether a pill can be clicked or arrowed into focus. Defaults to true in auto mode and false in scroll mode, where a click could not hold against the scrollbar.
labelstring"Highlights"Accessible name of the list.
classNamestringMerged onto the root through `cn` — this is where the width goes, since the size only sets a max.

FocusStackItem

Shape of a single entry in the `items` array.

Props for FocusStackItem
PropTypeDefaultDescription
id (required)stringStable identity for the rendered list item.
label (required)stringThe line of type on the pill.
descriptionstringSecond line, revealed only while the pill holds focus. Its space is reserved at every rank, so nothing reflows as the stack turns.
iconReact.ReactNodeDrawn inside the badge. Sized by the component, so pass a bare icon element.
accentstringAny CSS colour. Fills the badge; the glyph on top stays near-white. Omit both this and icon and the pill drops the badge entirely.

Dependencies

npm packages

  • motion

Registry items

  • utils

Installed automatically by the CLI when they are missing.

Accessibility

  • The pills render as an ordered list, so assistive technology reports both position and total, and the list is named by `label`.
  • The pill in focus carries `aria-current="true"` in both modes.
  • Depth is never the only signal: scale, opacity and blur are decoration over text that stays in the accessibility tree at full strength, so a screen reader gets the whole list rather than the three ranks that happen to be legible.
  • In auto mode the stack is a single tab stop with a roving tabindex, and the arrow keys move inside it — tabbing past a seven-item carousel does not cost seven stops.
  • WCAG 2.2.2 wants a mechanism to pause anything that moves by itself for more than five seconds. Autoplay stops on hover and on focus within, stops while the stack is scrolled out of view, and the `paused` prop hands the same switch to a control you own.
  • `prefers-reduced-motion` disables autoplay outright and drops the springs, so the stack becomes a still drawing that only moves when it is clicked, arrowed or scrolled.
  • Scroll mode adds no animation of its own: the position is tied to the scrollbar, so nothing plays that the reader did not drive.
  • A pill faded past the edge of the stack has its pointer events removed, so an invisible target can never take a click.
  • Badges are `aria-hidden`; the label carries the meaning, which is what keeps a pale accent from becoming an accessibility problem.
  • Both themes are covered by the tokens rather than by `dark:` variants, so the component keeps its contrast inside a forced-theme subtree.

Keyboard interactions

Keyboard interactions for Focus Stack
KeyBehaviour
Moves the focus one pill up the stack.
Moves the focus one pill down the stack.
HomeFocuses the first pill.
EndFocuses the last pill.
EnterSpaceBrings the pill under the caret into focus.

Customization

Turned by the page instead of by a timer

Scroll mode maps the focus to the stack's own transit through the viewport, so it works wherever it is dropped — no sticky wrapper, no scroll container, no height to calculate. Nothing animates on its own in this mode, which makes it the safer default on a page that already moves.

tsx
<FocusStack
  mode="scroll"
  label="Capabilities"
  items={items}
  onIndexChange={(index) => setHeadline(items[index].label)}
/>

Drive it yourself

Pass index and the stack becomes controlled — the spring still takes the shortest way round the ring, so stepping from the last item to the first is one step forward rather than a rewind through everything in between.

tsx
const [index, setIndex] = React.useState(0)

<FocusStack items={items} index={index} onIndexChange={setIndex} />

<button type="button" onClick={() => setIndex((value) => value + 1)}>
  Next
</button>

A pause control of your own

Autoplay already stops on hover, on focus and off screen, and never starts under prefers-reduced-motion. The paused prop is for the visible switch WCAG asks for on top of that.

tsx
const [paused, setPaused] = React.useState(false)

<FocusStack items={items} paused={paused} />

<button type="button" onClick={() => setPaused((value) => !value)}>
  {paused ? "Play" : "Pause"}
</button>

Reshape the coil

Three numbers own the whole drawing. Depth sets how many ranks survive either side of the focus and how tall the frame is, tilt sets the lean per rank, and blur sets the haze — drop it to 0 on a long list to lose the per-frame repaint.

tsx
{/* A tight, flat stack for a sidebar. */}
<FocusStack items={items} size="sm" depth={2} tilt={0} blur={0} />

{/* A wide, deep one for a hero, twisted the other way. */}
<FocusStack items={items} size="lg" depth={4} tilt={-7} className="max-w-2xl" />

Retint the badge glyph

Accents are per item and can be any CSS colour, so the palette belongs to your content rather than to the component. The glyph over them is near-white by default; if your accents are pale, redeclare it once instead of threading a second colour through every item.

css
/* app/globals.css */
:root {
  --focus-stack-glyph: oklch(0.24 0.02 60);
}
  • A colour-coded step tracker for orders, deployments and onboarding, still at rest and animated only when a step advances.