Stepper

.stepper answers one question: where am I, and how much is left. Each .step has a marker, a label and an optional note, and the markers are joined by a connector drawn from the marker's own ::after — so the line stops at the last step without a :last-child exception in the markup.

When to use it

Use it for a process with a fixed, known number of steps that the reader moves through in order: checkout, onboarding, a multi-page form. It is a progress indicator, not navigation — though the steps behind the current one can be links back.

Done, current, upcoming
  1. Details Ada Chen
  2. Billing Card ending 4242
  3. Confirm
<ol class="stepper"> <li class="step is-done"> <span class="step-marker"></span> <span class="step-label">Details</span> <span class="step-note">Ada Chen</span> </li> <li class="step is-current" aria-current="step"> <span class="step-marker"></span> <span class="step-label">Billing</span> <span class="step-note">Card ending 4242</span> </li> <li class="step"> <span class="step-marker"></span> <span class="step-label">Confirm</span> </li> </ol>
<ol class="stepper">
  <li class="step is-done">
    <span class="step-marker"></span>
    <span class="step-label">Details</span>
    <span class="step-note">Ada Chen</span>
  </li>
  <li class="step is-current" aria-current="step">
    <span class="step-marker"></span>
    <span class="step-label">Billing</span>
    <span class="step-note">Card ending 4242</span>
  </li>
  <li class="step">
    <span class="step-marker"></span>
    <span class="step-label">Confirm</span>
  </li>
</ol>

Three states

Everything is drawn from two classes on the step:

  • .is-done — the marker fills with the brand colour and shows a tick, the connector after it turns brand, and the label goes from muted to full text colour. The filled connector is what makes progress readable at a glance: the coloured part of the line is how far you have come.
  • .is-current — the marker gets a brand ring, and the label goes bold and full colour.
  • Neither — upcoming. Muted label, hollow marker, grey connector.

.is-done and .is-current are two of the nine states an author writes by hand. Every other is- class in Deck is set by JavaScript at runtime; these two describe where the server thinks the reader is, so they belong in the markup.

Vertical

Two ways to get a vertical stepper, and the difference is when.

  • .stepper-vertical — always vertical. The step becomes a row, the marker a column, and the connector switches from a horizontal 2px bar to a vertical one that stretches to fill the step's height.
  • .stepper-auto — horizontal where there is room, vertical below a breakpoint. Use this one by default: four steps with notes do not fit across a phone, and a horizontal stepper that squashes is unreadable.
.stepper-vertical — the connector runs down the markers
  1. Details Ada Chen
  2. Billing Card ending 4242
  3. Confirm
<ol class="stepper stepper-vertical"> <li class="step is-done"> <span class="step-marker"></span> <span class="step-label">Details</span> <span class="step-note">Ada Chen</span> </li> <li class="step is-current" aria-current="step"> <span class="step-marker"></span> <span class="step-label">Billing</span> <span class="step-note">Card ending 4242</span> </li> <li class="step"> <span class="step-marker"></span> <span class="step-label">Confirm</span> </li> </ol>
<ol class="stepper stepper-vertical">
  <li class="step is-done">
    <span class="step-marker"></span>
    <span class="step-label">Details</span>
    <span class="step-note">Ada Chen</span>
  </li>
  <li class="step is-current" aria-current="step">
    <span class="step-marker"></span>
    <span class="step-label">Billing</span>
    <span class="step-note">Card ending 4242</span>
  </li>
  <li class="step">
    <span class="step-marker"></span>
    <span class="step-label">Confirm</span>
  </li>
</ol>

Both vertical modes set min-inline-size: 0 on the step, so a long label or note wraps rather than pushing the strip wide — the same defence as .split > *.

When there are too many

.stepper scrolls horizontally with the scrollbar hidden, so a strip that does not fit does not wrap into an unreadable grid. That is the right failure mode, but it inherits the same problem as .scroller: with no scrollbar, the only cue that there are more steps is a partially visible one at the edge.

For more than about five steps, use .stepper-auto and let it go vertical, or stop showing every step and say "Step 3 of 8" instead. A stepper's job is telling the reader how much is left, and a scrolling strip does that badly.

Classes

.step and its parts are separate classes rather than members of the stepper component, so the completeness check on this page covers the three container classes only. They are documented here because a stepper is all of them together.

Classes, generated from the stylesheet source
Class Layer What it does Source
.stepper deck.components A wizard. The timeline in 07 records what happened; this one shows where you are in a process you are actively moving through. 22-nav.css:522
.stepper-auto deck.components Horizontal where there is room, vertical below a breakpoint. Prefer this over .stepper-vertical: four steps with notes do not fit across a phone, and a horizontal stepper that squashes is unreadable. 22-nav.css:601
.stepper-vertical deck.components Vertical on a phone, where five steps side by side never fits 22-nav.css:593
.step deck.components Sets flex, min-inline-size, display, flex-direction, and 3 more. 22-nav.css:530
.step-marker deck.components Sets display, align-items, gap. 22-nav.css:539
.step-label deck.components Sets font-size, font-weight, color. 22-nav.css:569
.step-note deck.components Sets font-size, color. 22-nav.css:570
.is-done deck.components No declarations. 07-components.css:807
.is-current deck.components No declarations. 22-nav.css:585

Tokens

Design tokens this component reads
Token Live Declared value
--brand light-dark(var(--brand-600), var(--brand-400))
--brand-500 oklch(60.8% var(--chroma-brand) var(--hue-brand))
--brand-600 oklch(51.6% var(--chroma-brand) var(--hue-brand))
--surface light-dark(var(--ink-0), var(--ink-950))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--text light-dark(var(--ink-900), var(--ink-100))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--text-faint light-dark(var(--ink-500), var(--ink-500))
--text-sm .8125rem
--text-xs .75rem
--r-full 999px
--space-3 .75rem
--space-5 1.25rem

Accessibility

  • Use an <ol>. The steps are ordered and the count matters, which is exactly what an ordered list conveys. Every example here does, and a <div> stepper tells a screen reader nothing about how many steps there are.
  • aria-current="step" on the current one. That is the attribute for this exact case — not aria-current="page", and not a class alone. Deck styles .is-current, so both are needed: the class for the look, the attribute for the announcement.
  • The tick and the ring are not announced. They are ::before content. A screen-reader user hears the labels and aria-current and nothing about which are complete — so add .sr-only text like "completed" to each done step if that matters.
  • State is not colour alone. Done gets a tick as well as a fill, current gets a ring as well as bold text. The connector's fill is colour alone, but it is a summary of information the markers already carry.
  • If steps are links, make them real links. A completed step that goes back should be an <a> inside the <li>; an upcoming step should not be focusable at all, since it leads nowhere yet.
  • The strip is a scroll container with no tab stop. Same defect as .table-wrap — if the steps are not links, a keyboard user cannot scroll to see the rest. Another reason to prefer .stepper-auto.
  • A stepper is not a progress bar. If you want the semantics, that is <progress> — but do not use both for the same thing, or the state is announced twice.

Right to left

The strip is a flex row that reverses under dir="rtl", and the vertical modes use flex-direction: column, which is unaffected. The connector is drawn by ::after inside the flex flow rather than positioned, so it follows the direction with no rule of its own.

Progress runs from the right
  1. التفاصيل
  2. الفوترة
  3. التأكيد
<ol dir="rtl" class="stepper"> <li class="step is-done"><span class="step-marker"></span><span class="step-label">التفاصيل</span></li> <li class="step is-current" aria-current="step"><span class="step-marker"></span><span class="step-label">الفوترة</span></li> <li class="step"><span class="step-marker"></span><span class="step-label">التأكيد</span></li> </ol>
<ol dir="rtl" class="stepper">
  <li class="step is-done"><span class="step-marker"></span><span class="step-label">التفاصيل</span></li>
  <li class="step is-current" aria-current="step"><span class="step-marker"></span><span class="step-label">الفوترة</span></li>
  <li class="step"><span class="step-marker"></span><span class="step-label">التأكيد</span></li>
</ol>

Reduced motion

Nothing about a stepper animates. Moving between steps is a page change or a re-render, not a transition, so prefers-reduced-motion has nothing to act on here.

Printing

.stepper is not in the never-print list, so it prints — which is reasonable for a checkout confirmation, where "you are at step 3 of 3" is part of the record. The connector and markers are backgrounds and are not in print-color-adjust: exact, so on paper the states rely on the tick and the label weight rather than on colour.

Being a scroll container, a wide stepper prints clipped — the same gap as the other horizontal scrollers in Deck.

Overriding it

@layer app.components {
  /* Numbered markers instead of ticks */
  .step .step-marker::before { content: counter(list-item); }
  .stepper { counter-reset: none; }

  /* Go vertical sooner */
  @media (max-width: 60rem) {
    .stepper-auto { flex-direction: column; gap: var(--space-5); }
  }
}

An <ol> already increments list-item, so numbering the markers needs no counter of your own — which is another small argument for using the right element.

When not to use it

  • Not when the number of steps is unknown. A stepper's value is showing how much is left. If the flow branches, it will show a path the reader may not take.
  • Not for more than about five steps. Past that it scrolls, and a scrolling progress indicator no longer indicates progress. Use "Step 3 of 8".
  • Not as navigation. Use tabs or .nav-link. A stepper implies an order the reader must follow.
  • Not for indeterminate progress. That is .progress or a .spinner — a stepper claims to know where the end is.
  • Not on a <div>. It works, and it throws away the count and the ordering that make the component mean anything.