Skip to content

Stepper

Guide a user through an ordered, often-gated process — showing where they are, what's done, and what's left.

Stablev0.4.2added in v0.1.0@garn/ui/stepper
On 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.

terminal
npx garn-ui add stepper
First time? Set up garn in your project
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

SelectorState
[data-slot=step][data-state=current]current
[data-slot=step][data-state=complete]complete
[data-slot=step][data-state=upcoming]upcoming
[data-slot=step][data-state=error]error
[data-slot=step][data-state=disabled]disabled
[data-slot=stepper-next-trigger][data-loading]loading
:focus-visiblefocus-visible
PropertyTokenTier
sectionGap--garn-gap-sectionsemantic · air
motion--garn-motion-basesemantic
ring--garn-ringbrandablesemantic

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

Do
  • 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 with defaultValue — 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; pass linear={false} to let any step be clicked.
Don't
  • 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" with look expecting 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 description for a brief subtitle; mark genuinely skippable steps optional.

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

warn

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

TabMove between interactive controls — clickable steps and the Prev/Next triggers.
EnterSpaceActivate the focused step (non-linear) or Prev/Next trigger.
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • 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.