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
"use client"

import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"

export interface StepperStep {
  id: string
  label: string
  description?: string
}

export interface StepperProps extends React.HTMLAttributes<HTMLOListElement> {
  steps: StepperStep[]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stepper.json

Prompt

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 < current ever call onStepClick, so it can only ever move the user backward. Upcoming steps are inert divs; the current step is an aria-disabled button with no handler, kept only so focus survives the complete-to-current flip after a jump back.
  • Status derived from two numbers — index and current are 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 explicit aria-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, not display: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.

On This Page