Skip to main content
Markdown

Stepper

Stepper guides users through a multi-step flow. Each step renders an indicator (status dot, status glyph, or custom content), an optional connecting rail, and a title with an optional description. The active step is index-based and can be controlled or uncontrolled.

Steps are real <button> elements: keyboard users activate them with Enter or Space, arrow keys (plus Home/End) move focus along the list, and the active step is announced through aria-current="step".

Usage

import { Stepper } from '@takeoff-ui/react-spar';
<Stepper>
<Stepper.Item>
<Stepper.Title />
<Stepper.Description />
</Stepper.Item>
</Stepper>

Playground


function PlaygroundDemo() {
  const [active, setActive] = React.useState(1);

  return (
    <div className="mx-auto flex w-full max-w-160 flex-col gap-6">
      <Stepper active={active} onActiveChange={setActive}>
        <Stepper.Item>
          <Stepper.Title>Flight</Stepper.Title>
          <Stepper.Description>Choose your route</Stepper.Description>
        </Stepper.Item>
        <Stepper.Item>
          <Stepper.Title>Passengers</Stepper.Title>
          <Stepper.Description>Traveler details</Stepper.Description>
        </Stepper.Item>
        <Stepper.Item>
          <Stepper.Title>Payment</Stepper.Title>
          <Stepper.Description>Card or miles</Stepper.Description>
        </Stepper.Item>
      </Stepper>
      <div className="flex justify-center gap-3">
        <Button size="small" variant="secondary" disabled={active === 0} onClick={() => setActive(active - 1)}>
          Back
        </Button>
        <Button size="small" disabled={active === 2} onClick={() => setActive(active + 1)}>
          Next
        </Button>
      </div>
    </div>
  );
}

render(<PlaygroundDemo />);

Step status

Steps before the active index show the completed treatment. Mark a step with error or disabled; both apply as modifiers without replacing the derived progress status. Disabled steps are natively disabled buttons — they are unfocusable and unselectable.


function StatusDemo() {
  return (
    <Stepper className="mx-auto max-w-160" defaultActive={3}>
      <Stepper.Item indicator={<span className="text-xs font-semibold">i</span>}>
        <Stepper.Title>Custom</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item error>
        <Stepper.Title>Error</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item disabled>
        <Stepper.Title>Disabled</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Active</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Inactive</Stepper.Title>
      </Stepper.Item>
    </Stepper>
  );
}

render(<StatusDemo />);

Numbered indicators

Pass a number through Stepper.Item's indicator prop when the indicator should show step numbers instead of the default status glyphs. indicator also accepts a render function receiving the step's status and index — returning undefined falls back to the built-in glyphs, so completed steps regain the check.


function NumberedIndicatorsDemo() {
  const steps = ['Search', 'Passengers', 'Payment'];

  return (
    <Stepper className="mx-auto max-w-160" defaultActive={1}>
      {steps.map((step, index) => (
        <Stepper.Item
          key={step}
          indicator={({ status }) =>
            status === 'completed' ? undefined : <span className="text-xs font-semibold">{index + 1}</span>
          }
        >
          <Stepper.Title>{step}</Stepper.Title>
        </Stepper.Item>
      ))}
    </Stepper>
  );
}

render(<NumberedIndicatorsDemo />);

Linear progression

With linear, users can revisit any previous step but only advance to the immediate next step — and only while the current step is neither errored nor disabled.


function LinearDemo() {
  const [active, setActive] = React.useState(0);

  return (
    <Stepper className="mx-auto max-w-160" linear active={active} onActiveChange={setActive}>
      <Stepper.Item>
        <Stepper.Title>Account</Stepper.Title>
        <Stepper.Description>Only the next step is clickable</Stepper.Description>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Verification</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Done</Stepper.Title>
      </Stepper.Item>
    </Stepper>
  );
}

render(<LinearDemo />);

Clickability

Use isClickable={false} when a step should stay in the visual flow but not change the active step on press.


function ClickabilityDemo() {
  const [active, setActive] = React.useState(0);

  return (
    <Stepper className="mx-auto max-w-160" active={active} onActiveChange={setActive}>
      <Stepper.Item>
        <Stepper.Title>Available</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item isClickable={false}>
        <Stepper.Title>Locked</Stepper.Title>
        <Stepper.Description>Visible but skipped</Stepper.Description>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Available</Stepper.Title>
      </Stepper.Item>
    </Stepper>
  );
}

render(<ClickabilityDemo />);

Reverse

Use reverse to flip indicators and content along the cross axis without changing the step order.


function ReverseDemo() {
  return (
    <Stepper className="mx-auto max-w-160" reverse defaultActive={1}>
      <Stepper.Item>
        <Stepper.Title>Origin</Stepper.Title>
        <Stepper.Description>Start city</Stepper.Description>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Seats</Stepper.Title>
        <Stepper.Description>Selected cabin</Stepper.Description>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Boarding</Stepper.Title>
        <Stepper.Description>Final details</Stepper.Description>
      </Stepper.Item>
    </Stepper>
  );
}

render(<ReverseDemo />);

Vertical


function VerticalDemo() {
  return (
    <div className="flex w-full justify-center">
      <Stepper orientation="vertical" defaultActive={1} style={{ maxWidth: 280 }}>
        <Stepper.Item>
          <Stepper.Title>Order placed</Stepper.Title>
          <Stepper.Description>We received your order</Stepper.Description>
        </Stepper.Item>
        <Stepper.Item>
          <Stepper.Title>Processing</Stepper.Title>
          <Stepper.Description>Tickets are being issued</Stepper.Description>
        </Stepper.Item>
        <Stepper.Item>
          <Stepper.Title>Ready</Stepper.Title>
          <Stepper.Description>Check your inbox</Stepper.Description>
        </Stepper.Item>
      </Stepper>
    </div>
  );
}

render(<VerticalDemo />);

Compact

mode="compact" drops the rails: each step carries a progress border that recolors with its status.


function CompactDemo() {
  const [active, setActive] = React.useState(1);

  return (
    <Stepper className="mx-auto max-w-120" mode="compact" size="small" active={active} onActiveChange={setActive}>
      <Stepper.Item>
        <Stepper.Title>Cabin</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Seats</Stepper.Title>
      </Stepper.Item>
      <Stepper.Item>
        <Stepper.Title>Extras</Stepper.Title>
      </Stepper.Item>
    </Stepper>
  );
}

render(<CompactDemo />);

Sizes


function SizesDemo() {
  return (
    <div className="mx-auto flex w-full max-w-160 flex-col gap-8">
      {['xsmall', 'small', 'base', 'large'].map(size => (
        <Stepper key={size} size={size} defaultActive={1}>
          <Stepper.Item>
            <Stepper.Title>{size}</Stepper.Title>
          </Stepper.Item>
          <Stepper.Item>
            <Stepper.Title>Active</Stepper.Title>
          </Stepper.Item>
          <Stepper.Item>
            <Stepper.Title>Next</Stepper.Title>
          </Stepper.Item>
        </Stepper>
      ))}
    </div>
  );
}

render(<SizesDemo />);

Accessibility

  • The root is an ordered list; each step is a list item wrapping a real <button> trigger, so steps are focusable and keyboard-activatable by default.
  • The active step's trigger carries aria-current="step".
  • Steps that cannot change the active step (non-clickable or blocked by linear) expose aria-disabled and stay silent on press; disabled steps are natively disabled and removed from the tab order.
  • Indicators and rails are decorative and hidden from assistive technology; a step's accessible name comes from its title text, extended with a visually hidden status suffix on completed/errored steps — localize it through the root's completedLabel/errorLabel props.
  • Stepper.Description is linked to the trigger through aria-describedby instead of inflating the accessible name.
  • Arrow keys along the stepper's orientation move focus between step triggers; Home and End jump to the first and last focusable step.

API Reference

Stepper

Props

NameTypeDefaultDescription
childrenReact.ReactNode-Stepper.Item elements. The root derives each step’s index from its position in children.
activenumber-Currently active step index (controlled). Not clamped: an out-of-range index renders every step completed (or inactive) with no active step.
defaultActivenumber0Initially active step index (uncontrolled).
orientationStepperOrientation'horizontal'Layout axis of the step list.
modeStepperMode'default'Display mode — indicators with connecting rails ('default') or a progress border per step without rails ('compact').
linearbooleanfalseRestricts navigation to a linear progression: any previous step, or the next step when the current one is neither errored nor disabled.
sizeStepperSize'base'Density scale for indicators and typography.
reversebooleanfalseFlips indicators and content along the cross axis.
completedLabelstring'completed'Accessible status suffix appended to a completed step's name — the check glyph alone is invisible to assistive technology. Localize per stepper; an empty string drops the suffix.
errorLabelstring'error'Accessible status suffix appended to an errored step's name — the error glyph alone is invisible to assistive technology. Localize per stepper; an empty string drops the suffix.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
classNamestring-Appends custom classes to the root slot of this part.

Events

NameTypeDefaultDescription
onActiveChange(index: number) => void-Fires with the new step index when the active step changes.
onStepClick(detail: StepperStepClickDetail) => void-Fires on selectable step presses, and on the active step when pressed again, with the step's index and progress status. Disabled, non-clickable, and linear-blocked steps emit nothing.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-orientationAlwaysReflects the resolved orientation prop (horizontal | vertical). Emitted by the wrapper.
data-modeAlwaysReflects the resolved mode prop (default | compact).
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.
data-linearWhen linear is true.Marks linear progression; selection gating itself is wrapper-owned.
data-reverseWhen reverse is true.Styling hook for the flipped indicator/content layout.

Stepper.Item

Props

NameTypeDefaultDescription
childrenReact.ReactNode-Step content — typically Stepper.Title and Stepper.Description. Renders inside the step’s <button> trigger and provides its accessible name.
errorboolean-Marks the step as errored without changing its progress status.
disabledboolean-Disables the step. The trigger renders as a natively disabled button: unfocusable, unselectable, and silent.
isClickablebooleantrueWhether the step can be activated by pressing it. Non-clickable steps stay visible in the flow but are removed from the tab order.
indicatorReact.ReactNode | ((state: StepperIndicatorState) => React.ReactNode)-Custom indicator content. Replaces the built-in status glyph (check, close, or dot) for every status except disabled. Pass a function to render by status — returning undefined or null falls back to the built-in glyphs, so numbered steps can surface the check once completed.
classNamesPartial<Record<StepperItemSlot, string>>-Per-slot class name overrides.
slotPropsPartial<Record<StepperItemSlot, React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-stateAlwaysResolved progress status: inactive | active | completed.
data-errorWhen error is true.Error treatment modifier; can coexist with any progress status.
data-disabledWhen disabled is true.Disabled treatment modifier; the trigger is natively disabled.
data-clickableWhen pressing the step may change the active step — never on the active step itself.Cursor/hover affordance hook. Respects disabled, isClickable, and linear gating.

Stepper.Title

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Stepper.Description

Data attributes

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.

Type Definitions

NameDefinition
StepperOrientation'horizontal' | 'vertical'
StepperMode'default' | 'compact'
StepperSize'large' | 'base' | 'small' | 'xsmall'
StepperStepClickDetail{ index: number; status: StepperStepStatus }
StepperIndicatorState{ status: StepperStepStatus; index: number }
StepperItemSlot'root' | 'trigger' | 'rail' | 'indicator' | 'content'