A 1-2-3-4 step indicator for wizards: numbered markers joined by connector lines,
per-step titles, and a description that reveals only on the step you're on. Forward
progress is earned — next() drives it, reached steps stay clickable to
jump back, and forward jumps beyond the furthest-reached step are blocked. It is
deliberately only the indicator: the panel and the Continue button below
are the app's, subscribed to the same engine. In a narrow container the horizontal
variant collapses to markers plus a centered title-and-description summary (the
markers already show your position, so there's no counter) — drag the width
slider.
Install: npx shadcn@latest add @lloydhumphreys/steps
The whole stage is one createStepsEngine({ steps }). The indicator is one
subscriber (createSteps({ engine })); the panel and the Back/Continue
buttons are two more, hand-rolled. Continue calls engine.next() — the only
call that grows the furthest-reached frontier — and the app disables it while the
active step is flagged with an error, which is exactly the gating pattern the engine
leaves to you on purpose. Step 4's Continue becomes Submit.
Walk forward a few steps, then click step 1: everything you reached keeps its
checkmark (furthest never shrinks on back-nav), and the steps ahead stay
clickable up to the frontier. Disable step 3 and Continue skips straight over
it — the marker gets a lock and refuses clicks. Drag Width under 560px: titles
hand over to the centered summary below the markers, driven by a CSS container query
on the indicator itself — no JS, no viewport breakpoint.
The vanilla core (steps.ts, zero dependencies). The React wrapper
(steps-react.tsx) exposes the same options as props plus a
useSteps hook; steps-shadcn is a separate registry item
with the whole thing rebuilt from Tailwind tokens and lucide icons — self-contained
in one file.
import { createStepsEngine, createSteps } from './steps'
const engine = createStepsEngine({
steps: [
{ id: 'account', title: 'Account', description: 'Choose a username.' },
{ id: 'payment', title: 'Payment', description: 'Card details.' },
{ id: 'team', title: 'Team', description: 'Invite teammates.' },
{ id: 'review', title: 'Review', description: 'Check and submit.' },
],
onChange(i, prev, reason) { analytics(reason) },
})
engine.subscribe(({ index, canNext, isLast }) => renderMyPanel(index))
host.appendChild(createSteps({ engine }).element)
myContinueButton.onclick = () => engine.next() // the only way forward
createStepsEngine(options) → StepsEngine| Option | Type | |
|---|---|---|
steps | StepItem[] | The steps: { id?, title, description?, icon?, disabled? }.
icon is a () => Node factory replacing the number
(and the built-in status icons). id defaults to
String(index). |
index | number = 0 | Initial step — an uncontrolled starting point (clamped, nudged off disabled steps); drive jumps through the engine. |
initialFurthest | number | Seed the frontier ahead of index when resuming a wizard whose
earlier steps are already complete. |
onChange | (index, prev, reason) → void | Index changed. reason is 'next', 'prev',
'goto' (marker click / clamp after a steps patch), or
'reset'. |
StepsEngine| Member | |
|---|---|
getState() |
{ index, id, count, furthest, steps, status, isFirst, isLast, canNext,
canPrev } — status is one derived
'upcoming' | 'active' | 'completed' | 'error' | 'disabled' per
step. |
subscribe(fn) |
State-change notifications. Returns unsubscribe. |
next() |
Advance to the next non-disabled step (skipping over disabled ones). The only
call that grows furthest. |
prev() |
Back to the previous non-disabled step. Never touches
furthest. |
goTo(target) |
Jump to a reached step, by index or id — refused for disabled steps and
anything beyond furthest; a silent no-op on the active
step. |
canGoTo(target) / indexOf(id) |
Lookups. |
setStepError(target, error) |
Flag/clear an explicit error, independent of navigation. Error beats active in
status, so the step you're on turns red — gate your own Continue
on it. |
reset() |
Back to the initial index and frontier; clears every error flag. |
setOptions({ steps }) |
Patch the steps live. Index and frontier clamp; if the active step became disabled, the engine moves to the nearest enabled one. |
destroy() | Drop subscribers. |
createSteps(options) → Steps — the indicator| Option | Type | |
|---|---|---|
engine | StepsEngine | Share an engine you own; engine options on this object are then ignored. Omitted: the indicator creates its own from the engine options above. |
orientation | 'horizontal' | 'vertical' | Vertical runs the connector down past each step's text. Only horizontal collapses in narrow containers. |
size | 'sm' | 'md' | 'lg' | Geometry preset; every dimension is also a --steps-*
variable. |
labels | { root?, step? } | Accessible names for the indicator and each step button. |
injectStyles | boolean = true | Auto-inject CSS; set false and ship stepsStyles() yourself. |
className | string | Extra class(es) on the root. |
Instance: element, engine, getState(),
next()/prev()/goTo(target),
setStepError(), reset(), setState(patch),
destroy(). Interactions: click a reached marker to jump back; Tab visits
reachable steps (unreached and disabled ones are natively
disabled, so they're skipped for free); arrows, Home and End move focus
between reachable steps.
The active <li> carries aria-current="step" — the
ARIA token that exists precisely for this — rather than tablist roles: the indicator
owns no panels, so there is no tab/tabpanel relationship to claim. No roving
tabindex either; these are ordinary buttons and the natural Tab order is correct.
The indicator never steals focus: programmatic next() from your own
Continue button re-renders it without touching focus. Completed markers derive from
furthest, so jumping back to review never un-completes anything. The
description reveal is a pure-CSS grid-template-rows: 0fr → 1fr
animation; under prefers-reduced-motion every transition here snaps
(nothing is informational-in-motion). Connector math is physical, not logical — RTL
isn't mirrored in this version.
Consumes shadcn theme tokens when present (--muted,
--muted-foreground, --primary,
--primary-foreground, --destructive, --border,
--foreground, --ring) with zinc light-dark()
fallbacks — standalone it still reads correctly in both themes. Override
independently via --steps-marker-bg/-fg,
--steps-marker-active-bg/-fg, --steps-marker-done-bg/-fg,
--steps-error/-fg, --steps-connector,
--steps-connector-fill, --steps-title,
--steps-title-active, --steps-description,
--steps-ring, --steps-radius,
--steps-marker-size, --steps-gap,
--steps-connector-size. The 560px collapse breakpoint is a literal
(container query conditions can't read custom properties) — to change it, ship an
edited stepsStyles() with injectStyles: false.