Deck
Browse the catalog

Build / Atoms

Stepper

  • atom
  • play-fn

Progress through a multi-step flow.

Built for the booking wizard, where each step is its own route (purpose → availability → details → booked) so a booking can be linked or resumed at any stage.

Completed steps are navigable; upcoming ones are inert. State is conveyed with text and aria-current, not only the marker's color, and the whole list is a <nav> with an ordered list so the sequence is announced.

A step marked terminal is complete on arrival — the end of a flow asks for nothing, so it shows a check rather than a number it will never move past. It stays inert, since there is no step after it to return from.

import { Stepper } from '@meetcard/deck'

Example

<Stepper currentStepId="availability" steps={[ { id: 'purpose', label: 'Purpose', href: './purpose' }, { id: 'availability', label: 'Time' }, { id: 'details', label: 'Details' }, { id: 'booked', label: 'Confirmed', terminal: true }, ]} />

Stories

Every preview below is the real component, rendered in this page from the same story Storybook runs. The width control resizes each preview's container; CSS media queries still follow the browser window, so breakpoint behaviour is worth checking in Storybook.

Default
Source
const Default = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={[
          { id: 'purpose', label: 'Purpose' },
          { id: 'availability', label: 'Time' },
          { id: 'details', label: 'Details' },
          { id: 'booked', label: 'Confirmed' },
        ]}
        currentStepId="availability"
        onSelect={fn()} />
</div>;
First Step
Source
const FirstStep = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={[
          { id: 'purpose', label: 'Purpose' },
          { id: 'availability', label: 'Time' },
          { id: 'details', label: 'Details' },
          { id: 'booked', label: 'Confirmed' },
        ]}
        currentStepId="purpose"
        onSelect={fn()} />
</div>;
Complete
Source
const Complete = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={[
          { id: 'purpose', label: 'Purpose' },
          { id: 'availability', label: 'Time' },
          { id: 'details', label: 'Details' },
          { id: 'booked', label: 'Confirmed' },
        ]}
        currentStepId="booked"
        onSelect={fn()} />
</div>;

A step marked terminal is complete the moment it is reached. The marker settles from white through the success tint to the filled disc, drawing its check — the resting style is the finished one, so with motion off the same check is simply already there.

Terminal Step
Source
const TerminalStep = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={bookingSteps.map((step) =>
          step.id === 'booked' ? { ...step, terminal: true } : step,
        )}
        currentStepId="booked"
        onSelect={fn()} />
</div>;

Phone. Every step keeps its label, stacked under its own marker — a row of bare numbers tells you where you are and nothing about what it is out of, which is most of the point of a progress indicator.

Five steps is the widest the booking flow gets (Book with Team adds a Team step), so it is the case worth pinning.

Mobile
Source
const Mobile = () => <Stepper
    steps={[
      { id: 'purpose', label: 'Purpose' },
      { id: 'availability', label: 'Availability' },
      { id: 'team', label: 'Team' },
      { id: 'details', label: 'Details' },
      { id: 'booked', label: 'Booked', terminal: true },
    ]}
    currentStepId="availability"
    onSelect={fn()} />;

Completed steps are navigable so a visitor can revise an earlier choice.

Completed Steps Are Navigable
Source
const CompletedStepsAreNavigable = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={[
          { id: 'purpose', label: 'Purpose' },
          { id: 'availability', label: 'Time' },
          { id: 'details', label: 'Details' },
          { id: 'booked', label: 'Confirmed' },
        ]}
        currentStepId="details"
        onSelect={fn()} />
</div>;

With href, steps render as links so each is a real, shareable route.

As Links
Source
const AsLinks = () => <div style={{ maxWidth: 480 }}>
    <Stepper
        steps={bookingSteps.map((step) => ({
          ...step,
          href: `/ben/book/${step.id}`,
        }))}
        currentStepId="details"
        onSelect={undefined} />
</div>;

The same flow at every stage, for checking each state at once.

Every Stage
Source
const EveryStage = (args) => (
  <Stack gap={24} style={{ maxWidth: 480 }}>
    {/* Each nav landmark needs a unique accessible name when several
        appear on one page — hence the per-stepper `label`. */}
    {bookingSteps.map((step) => (
      <Stepper
        {...args}
        key={step.id}
        currentStepId={step.id}
        label={`Progress: ${step.id}`}
      />
    ))}
  </Stack>
);

Props

PropTypeDefaultDescription
currentStepIdrequiredstring—id of the step in progress.
stepsrequiredStep[]——
labelstring'Progress'Accessible name for the progress nav.
onSelect(stepId: string) => void—Called when a completed step is activated, for non-link navigation.

Also accepts everything from Omit, so id, data-* and event handlers pass straight through.

Open in Storybookfor controls, the a11y panel and the interaction runner.