Skip to content

Stepper

Multi-step progress in horizontal and vertical orientations, with state conveyed in words as well as ticks.

Navigationstarternavigationwizardonboardingcheckoutprogress

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Stepper } from '@/components/ui/stepper'
import { DemoRow, DemoStage } from './_kit'

const steps = [
  { id: 'account', label: 'Account', description: 'Name and email' },
  { id: 'workspace', label: 'Workspace', description: 'Region and slug' },
  { id: 'team', label: 'Invite team', description: 'Optional' },
  { id: 'done', label: 'Review', description: 'Confirm and finish' },
]

export default function StepperDemo() {
  return (
    <DemoStage>
      <DemoRow label="Horizontal" description="Stacks vertically below the sm breakpoint.">
        <Stepper steps={steps} current={1} className="w-full" />
      </DemoRow>

      <DemoRow label="Final step">
        <Stepper steps={steps} current={3} className="w-full" />
      </DemoRow>

      <DemoRow label="Vertical" description="For sidebars and narrow columns.">
        <Stepper steps={steps} current={2} orientation="vertical" className="w-full max-w-xs" />
      </DemoRow>
    </DemoStage>
  )
}

components/demos/stepper.tsx

import { Stepper } from '@/components/ui/stepper'
import { DemoRow, DemoStage } from './_kit'

const steps = [
  { id: 'account', label: 'Account', description: 'Name and email' },
  { id: 'workspace', label: 'Workspace', description: 'Region and slug' },
  { id: 'team', label: 'Invite team', description: 'Optional' },
  { id: 'done', label: 'Review', description: 'Confirm and finish' },
]

export default function StepperDemo() {
  return (
    <DemoStage>
      <DemoRow label="Horizontal" description="Stacks vertically below the sm breakpoint.">
        <Stepper steps={steps} current={1} className="w-full" />
      </DemoRow>

      <DemoRow label="Final step">
        <Stepper steps={steps} current={3} className="w-full" />
      </DemoRow>

      <DemoRow label="Vertical" description="For sidebars and narrow columns.">
        <Stepper steps={steps} current={2} orientation="vertical" className="w-full max-w-xs" />
      </DemoRow>
    </DemoStage>
  )
}

components/ui/stepper.tsx

import { Check } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Stepper
 *
 * Progress through a multi-step flow. Rendered as an ordered list so the count
 * and order are conveyed structurally, with `aria-current="step"` on the
 * active step and a visually hidden status word ("completed", "current") on
 * each — the connector line and tick alone are not information.
 */
export interface Step {
  id: string
  label: string
  description?: string
}

export interface StepperProps {
  steps: Step[]
  /** Zero-based index of the current step. */
  current: number
  orientation?: 'horizontal' | 'vertical'
  className?: string
  label?: string
}

export function Stepper({
  steps,
  current,
  orientation = 'horizontal',
  className,
  label = 'Progress',
}: StepperProps) {
  return (
    <nav aria-label={label} className={className}>
      <ol
        className={cn(
          orientation === 'horizontal'
            ? 'flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-0'
            : 'flex flex-col',
        )}
      >
        {steps.map((step, index) => {
          const complete = index < current
          const active = index === current
          const isLast = index === steps.length - 1

          return (
            <li
              key={step.id}
              aria-current={active ? 'step' : undefined}
              className={cn(
                'flex min-w-0 gap-3',
                orientation === 'horizontal' ? 'sm:flex-1 sm:flex-col sm:gap-2' : 'pb-6 last:pb-0',
              )}
            >
              <div
                className={cn(
                  'flex items-center gap-3',
                  orientation === 'vertical' && 'flex-col self-stretch',
                )}
              >
                <span
                  className={cn(
                    'flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold',
                    complete && 'border-accent bg-accent text-accent-ink',
                    active && 'border-accent bg-accent-soft text-accent-soft-ink',
                    !complete && !active && 'border-line bg-surface text-ink-subtle',
                  )}
                >
                  {complete ? <Check className="size-3.5" aria-hidden="true" /> : index + 1}
                </span>
                {!isLast ? (
                  <span
                    aria-hidden="true"
                    className={cn(
                      orientation === 'horizontal'
                        ? 'hidden h-px flex-1 sm:block'
                        : 'w-px flex-1 self-center',
                      complete ? 'bg-accent' : 'bg-line',
                    )}
                  />
                ) : null}
              </div>

              <div className={cn('min-w-0', orientation === 'horizontal' && 'sm:pr-4')}>
                <p
                  className={cn(
                    'text-sm leading-snug font-medium',
                    active ? 'text-ink-strong' : complete ? 'text-ink' : 'text-ink-muted',
                  )}
                >
                  {step.label}
                  <span className="sr-only">
                    {complete ? ' — completed' : active ? ' — current step' : ' — not started'}
                  </span>
                </p>
                {step.description ? (
                  <p className="mt-0.5 text-xs text-ink-muted">{step.description}</p>
                ) : null}
              </div>
            </li>
          )
        })}
      </ol>
    </nav>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Pairs with any multi-step form. It answers three questions at once: how many steps there are, which one you are on, and how much is left.

  • Keep step labels to one or two words and put the detail in the description.
  • Five steps is usually the ceiling; beyond that, group them.
  • The connector and tick are decorative — the state is also in a visually hidden word.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Horizontal — stacks below sm
  • Vertical
  • Completed, current and upcoming states

Accessibility

Structure
An ordered list inside a labelled `<nav>`, so count and order are structural.
Current step
`aria-current="step"` on the active item.
State in text
Each step announces "completed", "current step" or "not started".

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.