Stepper
Guide a user through an ordered, often-gated process — showing where they are, what's done, and what's left.
@garn/ui/stepperOn this page
Default
A horizontal checkout — labels beside the indicators, filled connectors behind completed steps.
Layout
Horizontal
A horizontal checkout — labels beside the indicators, filled connectors behind completed steps.
Label Below
labelPlacement="bottom" — labels under each indicator.
Vertical
A vertical flow-rail timeline.
Sub Steps
subStep — finer dot markers nested under a milestone (best in vertical).
Indicator
Dots
variant="dot" — minimal dot indicators.
Custom Icons
A per-step `icon` that persists across states.
Outline
look="outline" — the line language (dashed upcoming, dark-neutral complete, ! error, per-step top tails).
State
States
Every step state — complete · current · error · optional · disabled.
Loading
`loading` — a spinner in the indicator while a step processes.
Sizes
sm · md · lg.
Behavior
Clickable
linear={false} — click any step to jump (controlled value + onValueChange).
Wizard
The full wizard — StepContent panels, StepperCompleted, Prev/Next, and an async onBeforeStepChange gate.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add stepper- Registry
stepper- Deps
@radix-ui/react-slotclass-variance-authoritylucide-react- Registry deps
utilsuse-controllable-statespinner
Import
import { StepperList, Step, StepIndicator, StepSeparator, StepTitle, StepDescription, StepContent, StepperCompleted, StepperPrevTrigger, StepperNextTrigger } from "@garn/ui/stepper";Anatomy
The parts this component is built from — compose them to assemble it.
StepperListRequired[data-slot="stepper-list"]The step rail — a <nav> landmark wrapping an <ol>. Give it an accessible name via aria-label (or the Stepper label prop). Counts and indexes its Step children.
StepRequired[data-slot="step"]One step — an <li>. Terse mode: pass title / description / icon / completedIcon / optional / error / disabled / loading / subStep. Compound mode: compose StepIndicator / StepTitle / … as children. The active step's li carries aria-current="step"; data-state reflects upcoming | current | complete | error | disabled.
StepIndicator[data-slot="step-indicator"]The circle/dot (aria-hidden). Numbered renders the step number → ✓ on complete → ✕/! on error → a custom icon → a spinner while the async gate is pending. Dots are look-agnostic.
StepSeparator[data-slot="step-separator"]The connector between steps. Fills (scaleX/scaleY of --stepper-progress) once the preceding step is complete; data-state = complete | incomplete.
StepTitle[data-slot="step-title"]The step's label; its id labels the matching StepContent panel.
StepDescription[data-slot="step-description"]Secondary text under the title (also renders the 'Optional' caption).
StepContent[data-slot="step-content"]A per-step panel (role=region) matched to a step by position; labelled by its StepTitle, hidden when inactive, tabIndex=0 when active. Omit for an indicator-only or route-based stepper.
StepperCompleted[data-slot="stepper-completed"]The done-state panel — rendered only once the active index passes the last step.
StepperPrevTrigger[data-slot="stepper-prev-trigger"]A back button — auto-disabled at the start. asChild to wrap a custom Button. Back is never gated.
StepperNextTrigger[data-slot="stepper-next-trigger"]A next button — auto-disabled at the end and while the async gate is pending (data-loading). asChild to wrap a custom Button.
Props
The component's public props and their types.
labelPlacement- Description
- Horizontal only: label beside the indicator (`end`) or under it (`bottom`).
- Type
"bottom" | "end"- Default
"end"
look- Description
- Indicator visual language for numbered steps: `filled` (solid brand circles — bold, use for short primary flows) or `outline` (a lighter line language — dashed upcoming rings, dark-neutral complete, `!` on error, per-step top tails in horizontal — for longer or denser flows). Dots ignore this.
- Type
"filled" | "outline"- Default
"filled"
orientation- Description
- Lay the steps out in a row or a vertical rail.
- Type
"horizontal" | "vertical"- Default
"horizontal"
size- Description
- Indicator + label scale.
- Type
"lg" | "md" | "sm"- Default
"md"
variant- Description
- Indicator shape: a numbered circle or a plain dot.
- Type
"dot" | "numbered"- Default
"numbered"
defaultValue- Description
- Uncontrolled initial active step index.
- Type
number- Default
0
label- Description
- Accessible name for the step list (the `nav` landmark).
- Type
string
linear- Description
- Gate forward navigation so only completed steps are revisitable; set false to let any step be clicked directly.
- Type
boolean- Default
true
onBeforeStepChange- Description
- Async forward-gate. Return `false` (or a Promise of it) to block the move; the target indicator shows a spinner while pending. Back is never gated.
- Type
(details: BeforeStepChangeDetails) => boolean | Promise<boolean>
onValueChange- Description
- Called with the new step index whenever the active step changes.
- Type
(index: number) => void
value- Description
- Controlled active step index (0-based).
- Type
number
Plus 278 inherited native <button> attributes.
Step
completedIcon- Description
- Custom completed indicator (overrides the check).
- Type
React.ReactNode
description- Description
- Terse mode: secondary text under the title.
- Type
React.ReactNode
disabled- Description
- Disable the step — it dims and can't be navigated to.
- Type
boolean- Default
false
error- Description
- Mark the step as failed (error indicator + danger tone).
- Type
boolean- Default
false
icon- Description
- Custom indicator content (overrides the number/dot).
- Type
React.ReactNode
loading- Description
- Show a spinner in the indicator (e.g. this step is processing).
- Type
boolean- Default
false
optional- Description
- Mark the step optional (renders an "Optional" caption).
- Type
boolean- Default
false
subStep- Description
- Render as a smaller sub-step dot in the rail (a finer-grained marker that still counts in the sequence). Best in vertical orientation.
- Type
boolean- Default
false
title- Description
- Terse mode: the step's label.
- Type
React.ReactNode
value- Description
- Stable id, used to match a `StepContent` panel by value (optional).
- Type
string
Plus 278 inherited native HTML attributes.
StepContent
value- Description
- Match this panel to a step by its `value` (otherwise matched by position).
- Type
string
Plus 280 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="stepper"]States
| Selector | State | Description |
|---|---|---|
[data-slot=step][data-state=current] | current | The active step (aria-current="step"); brand-filled indicator. |
[data-slot=step][data-state=complete] | complete | A finished step — ✓ (or completedIcon); its trailing connector fills. |
[data-slot=step][data-state=upcoming] | upcoming | A not-yet-reached step — muted (dashed ring under look=outline). |
[data-slot=step][data-state=error] | error | A failed step (`error`) — ✕ (filled) or ! (outline), danger tone. |
[data-slot=step][data-state=disabled] | disabled | A non-interactive, dimmed step (`disabled`). |
[data-slot=stepper-next-trigger][data-loading] | loading | The async gate is pending — Next is disabled and the target indicator shows a spinner. |
:focus-visible | focus-visible | 2px ring + offset on interactive step triggers, Prev/Next, and the active panel. |
| Property | Token | Tier |
|---|---|---|
sectionGap | --garn-gap-section | semantic · air |
motion | --garn-motion-base | semantic |
ring | --garn-ringbrandable | semantic |
Private instance vars (never externalize): --stepper-progress
When to use
Guide a user through an ordered, often-gated process — a checkout, an onboarding wizard, a multi-section form — showing where they are, what's done, and what's left.
Reach for something else when
- Switching between peer views in any order (use tabs)
- showing where a page sits in a hierarchy (use breadcrumb)
- moving through a paged result set (use pagination)
- a single open/close region (use collapsible).
Overview
Stepper guides a user through an ordered, often-gated process — a checkout, an onboarding wizard, a multi-section form — showing where they are, what's done, and what's left. StepperList holds each Step (with its StepIndicator, StepTitle, and StepSeparator), reflecting complete / current / upcoming state. Use it for sequential, dependent steps; for switching between peer views use Tabs, and to show where a page sits in a site hierarchy use Breadcrumb.
Guidelines
Use a stepper only for a genuine sequence. It implies order and progress — right for a flow with dependent stages, wrong for independent sections (that's Tabs). Keep the number of steps small and group sub-tasks rather than exposing a dozen.
Always communicate position and status. Make the current step, completed steps, and what remains obvious; name each step by its outcome ("Shipping", "Payment") so users can anticipate the path. Show validation per step before advancing.
Be clear about gating and back-navigation. If steps are gated, explain why a later step is locked; let users go back to completed steps without losing data. Keep it keyboard-navigable and announce step changes for assistive tech.
Best practices
- Give the step list an accessible name (the Stepper
label, or aria-label on StepperList). - Control the active step via
value+onValueChange, or leave it uncontrolled withdefaultValue— the value is the 0-based INDEX. - Gate forward moves with
onBeforeStepChange(return false / a Promise of it); Back is always free. - Add StepContent panels (matched by position) to own the content, or omit them for a route-based wizard.
- Use
linear(default) for a gated flow; passlinear={false}to let any step be clicked.
- Don't use a stepper for unordered peer views — that's tabs.
- Don't pass a step id to
value; it's the 0-based step index. - Don't combine
variant="dot"withlookexpecting a change — dots are look-agnostic. - Don't strip the focus ring or the aria-current wiring — they are the keyboard + screen-reader contract.
Content guidelines
- Keep step titles short and parallel (one or two words) so the rail fits on one line.
- Use
descriptionfor a brief subtitle; mark genuinely skippable stepsoptional.
Troubleshooting
Passing a step name/id to value does nothing or jumps to the wrong step.
Cause. value / defaultValue is the 0-based step INDEX, not a step id.
Fix. Pass the index (0, 1, 2…); read the index back from onValueChange.
variant="dot" + look="outline" looks identical to look="filled".
Cause. Dots are look-agnostic — look only affects numbered indicators.
Fix. Use look with the default numbered variant; for dots, drop look.
Next advances immediately even though validation should block it.
Cause. onBeforeStepChange isn't returning false (or a Promise resolving to false) to veto the move.
Fix. Return false / Promise<false> to block; the target indicator shows a spinner while the promise is pending. Back is never gated.
Clicking Back runs the validation gate.
Cause. Expecting the gate to fire on both directions.
Fix. By design, only forward moves are gated; Back is always free.
The StepContent panel never shows.
Cause. Panels are matched to steps by POSITION (declaration order), not by a value prop.
Fix. Render StepContent panels in the same order as the Steps; the Nth panel pairs with the Nth step.
A dev console warns the step list has no accessible name.
Cause. Neither the Stepper label nor StepperList aria-label is set.
Fix. Pass label on Stepper (or aria-label on StepperList).
Accessibility
- Role
navigation- Focus
- 2px ring in --garn-ring + offset on interactive step triggers, Prev/Next, and the active panel.
Accessibility requirements
Give the step list an accessible name via the Stepper `label` prop, or `aria-label` on StepperList.
when the StepperList has no `aria-label` / `aria-labelledby` and the Stepper has no `label`
Keyboard
| Tab | Move between interactive controls — clickable steps and the Prev/Next triggers. |
| EnterSpace | Activate the focused step (non-linear) or Prev/Next trigger. |
- The root is a <nav> landmark > <ol>; each Step is an <li>; the active step's <li> (and its trigger, if interactive) carries aria-current="step".
- Indicators are aria-hidden; status reaches assistive tech via a single role=status, aria-live=polite region announcing "Step N of M: <title>".
- StepContent is role=region, aria-labelledby its StepTitle id, hidden when inactive; an interactive step aria-controls its panel ONLY when one exists.
- The tablist model is deliberately avoided — it implies free navigation that conflicts with linear gating, and the roles-without-keyboard-contract trap.
- Give the step list an accessible name via the Stepper `label` prop or aria-label on StepperList.