Navigation
Stepper
A horizontal wizard progress indicator — completed steps are clickable to go back, the current and future steps never are.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stepper.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Stepper" component (deps: lucide-react
for the check icon).
Contract
- export const Stepper = React.forwardRef<HTMLOListElement, StepperProps>
rendering an <ol>; spread remaining native ol props on the root, merge
className via cn().
- steps: { id: string; label: string; description?: string }[] — description
is optional per-step caption text.
- current: number — 0-based index of the active step; the component holds no
internal state, the consumer drives it.
- onStepClick?: (index: number) => void — when provided, completed steps
(index < current) render as real <button>s that call it, and the current
step renders as a <button aria-disabled="true"> with no handler and the
name "Step {n}: {label} (current)". That button exists for focus, not for
clicks: jumping back flips the clicked step from complete to current, and
swapping its button for a div would unmount the node that just took focus
and drop focus to <body>. Upcoming steps always render as a plain <div>, and
so does every step when onStepClick is omitted — users can only ever jump
backward, never skip ahead.
Behavior
- Derive per-step status from index vs current: index < current = "complete",
index === current = "current", otherwise "upcoming".
- The connector line between step i and step i+1 is bg-primary when step i is
complete (index < current), otherwise bg-border — so the filled portion
always tracks how far the user has actually progressed, not just the
current position.
- Clicking a completed step's circle calls onStepClick(index); the button's
accessible name is "Go back to step {n}: {label}" since the circle's
glyph (number or check) is aria-hidden and conveys nothing on its own.
- The active <li> carries aria-current="step"; the circle's inner number/Check
is decorative (aria-hidden) — the label text underneath is what a screen
reader actually announces for each step. The status the check glyph and
colours show is spoken too: completed and upcoming labels end with an
sr-only ", completed" / ", upcoming".
- Narrow screens: only the current step's label/description stay visible
below the sm breakpoint so a long step list never overflows or wraps into a
jumbled column; the rest get sr-only sm:not-sr-only, never hidden —
display:none would leave those <li>s with no accessible content at all,
since the circle is aria-hidden. The circles and connectors always stay in
a single row regardless of width.
Rendering & styling
- Semantic tokens only. Complete circle: border-primary bg-primary
text-primary-foreground with a lucide Check. Current circle: border-primary
text-primary plus a ring-2 ring-primary/25 ring-offset-2
ring-offset-background halo. Upcoming circle: border-muted
text-muted-foreground. The current step's label is font-medium
text-foreground; every other label and every description stay
text-muted-foreground.
- Layout: <ol className="flex w-full">, each <li> is a flex row of
[circle+label column, connector] where the column is shrink-0 and the
connector is flex-1 (only non-last steps grow to fill the gap); the
connector sits at mt-[18px] so it lines up with the circle's vertical
center (half of the size-9 circle).
- Focus-visible ring on the clickable button; merge the consumer's className
onto the root <ol> via cn().
Customization levers
- Vertical variant: swap the <ol> to flex-col, turn the connector's flex-1
width into a flex-1 height bar between stacked circles, and let labels sit
beside the circle instead of below it — the status/click logic is
orientation-independent.
- Click policy: pass onStepClick to let completed steps jump back (the
default here), omit it entirely for a fully static/read-only progress
display, or loosen the "complete" condition in the clickable check to let
users click any step — including upcoming ones — if the flow doesn't need
to guard against skipping.
- Connector animation: add a transition-[width] (or a scaleX transform)
driven by the same index < current condition to animate the fill sweeping
in as current advances, instead of the default instant color swap.
- Density/size: size-9 circles and the sm label breakpoint are the only two
sizing knobs — swap to size-7/size-11 and adjust mt-[18px] to match the new
half-height, or move the breakpoint (sm → md) to keep labels hidden longer
on medium screens.Concepts
- No-skip-ahead gating — only steps with
index < currentever callonStepClick, so it can only ever move the user backward. Upcoming steps are inertdivs; the current step is anaria-disabledbutton with no handler, kept only so focus survives the complete-to-current flip after a jump back. - Status derived from two numbers —
indexandcurrentare the only inputs; there's no separate "completed steps" array to keep in sync, so the visual state can never drift from the position the consumer already tracks. - Connector as progress trail — each line segment mirrors whether the step behind it is done, turning the row of circles into one continuous progress trail rather than N independent badges.
- Decorative glyph, spoken label — the number/check inside the circle is
aria-hidden; the accessible name comes from the visible label (or the button's explicitaria-label), so screen readers hear "Account, Profile, Confirm" instead of "1, 2, 3". - Responsive label collapse — visually hiding non-current labels below
sm(sr-only, notdisplay:none) keeps the step row itself from ever needing horizontal scroll while every step still has a spoken name and status; only the active step's caption is guaranteed visible at every width.