← all demos

steps

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

Orientation
Size
Width 760px
Chaos
Theme
One engine, three subscribers: the indicator, the panel, and the buttons.

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.

API

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

OptionType
stepsStepItem[] 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).
indexnumber = 0 Initial step — an uncontrolled starting point (clamped, nudged off disabled steps); drive jumps through the engine.
initialFurthestnumber 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

OptionType
engineStepsEngine 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.
injectStylesboolean = true Auto-inject CSS; set false and ship stepsStyles() yourself.
classNamestring 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.

Behavior notes

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.

Theming

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.