Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Stepper

Stepper shows where someone stands in a multi-step process, such as a checkout flow, a multi-page form, or an onboarding wizard. It lays out the whole sequence of steps and reports which one is currently active. Moving to a different step is only ever requested, never applied on its own, so the surrounding application stays in charge of validating and advancing the process itself.

Dependencies

eun-icon · for a completed or errored step's glyph eun-progress-bar · for the progress display
Overview API Examples Accessibility

When to use

Use a stepper whenever someone needs to see, and optionally move between, the discrete steps of a process with a clear beginning and end, such as a checkout, a multi-page signup form, or a setup wizard. It shows both where someone currently stands and how much is left, which a plain progress bar alone cannot, since it has no notion of separate named steps, and which tabs should not be stretched to cover either, since tabs are peer views someone can revisit in any order at any time rather than a sequence with a beginning, an end, and often validation gating how far forward someone can go.

Install & usage

Pick a framework in the toolbar above and these snippets adapt.

npm install @eunomia/elements
import "@eunomia/elements/stepper.js";
<eun-stepper
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>

<script>
  document
    .querySelector("eun-stepper")
    .addEventListener("eunstepchange", (event) => {
      // Apply the change yourself : validate the step being left, fetch
      // whatever the new one needs, ... eun-stepper never sets
      // activeStep back itself.
      event.target.activeStep = event.step;
    });
</script>

Importing the file registers <eun-stepper> as a custom element, with no further setup needed. It works with any framework, or none, since it's a standard web component. Ownership of activeStep stays entirely with your own code, since eun-stepper only reports which step was requested through eunstepchange and never sets activeStep back itself (see the API tab).

Need a step's label to hold more than plain text, such as an icon or a badge? Slot eun-step elements in instead of using the steps array. Import @eunomia/elements/step.js too, the same way eun-breadcrumb-item needs its own import alongside eun-breadcrumb. See "Slotted items" in the Examples tab for how it customizes each step's content.

import "@eunomia/elements/stepper.js";
import "@eunomia/elements/step.js";
<eun-stepper active-step="1">
  <eun-step>Cart</eun-step>
  <eun-step>Shipping</eun-step>
  <eun-step>Payment</eun-step>
</eun-stepper>

Alternatives

You want .. Prefers A simple advancement indicator with no separate named steps eun-progress-bar Peer views someone can revisit in any order, not a validated sequence eun-tab-group

Guidance

  • Keep the default linear behavior whenever later steps genuinely depend on earlier ones being filled in correctly, since it's what stops someone jumping straight to Payment with an empty cart
  • Drive activeStep forward only after validating the step being left, in your own eunstepchange handler (or your own Next button's click handler, see the Examples tab)
  • Use substeps only for a step that's genuinely a small sequence of its own (e.g. "Address" then "Delivery method"), not as a way to cram two unrelated steps into one
  • Reach for --stepper-mobile when the stepper needs to shrink to a compact, circles-only strip, since it keeps every label as the control's accessible name, unlike hiding them with display: none yourself
  • Setting linear="false" on a process where a later step actually depends on an earlier one being valid, since that removes the one safeguard the default gives you for free
  • Relying on the progress variant alone when the step names themselves matter to the person using it, given that it deliberately drops labels, descriptions and substeps
  • Wiring orientation="vertical" onto variant="progress", since the progress bar is always a single horizontal bar and ignores it entirely
  • Expecting the substeps ring to be clickable, since it's a decorative progress summary for the current step, not a set of individually navigable controls

Live testing

Properties

Stepper <eun-stepper>

Attributes

NameTypeDefaultDescription
stepsArray<EunomiaStepperStepData>[]The process's steps, in order. Ignored once any step children are slotted in
active-stepnumber0The zero-based index of the current step
active-substepnumberThe zero-based index, into the active step's substeps, of the current substep. Meaningless when that step has no substeps
linearbooleantrueRestricts direct navigation to steps before activeStep. Set to false to allow jumping to any non-disabled step in any order
aria-labelstring'Progress'The accessible label for the navigation landmark. Numbered display only
orientation'horizontal' | 'vertical''horizontal'The numbered display's layout direction. Has no effect on the progress variant
variant'numbered' | 'progress''numbered'Which of the two displays to render
disabledbooleanfalseDisables every step's control, leaving a read-only progress summary with no navigation at all, the same way an individual step's own disabled does

Import the exact TypeScript type behind any property above, see Types.

Slots

NameDescription
(default)Step elements, used instead of steps. Numbered display only

Events

NameTypeDescription
eunstepchangeStepChangeEventFired with the requested step whenever a reachable step is activated. ActiveStep and activeSubstep are never updated internally

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--stepper-mobileSet to true, from a media query, a script, or an ancestor's own custom property, since it inherits like any CSS custom property, to force the compact layout, hiding labels and descriptions regardless of the stepper's own rendered width
--stepper-gapSets the gap between a step's content and its neighboring connector
--stepper-connector-lengthSets the minimum width of a horizontal connector. A vertical connector's length is never set directly, since it always spans exactly the gap left by its own step's rendered height
--stepper-connector-colorSets the color of a connector ahead of a step that hasn't been reached yet
--stepper-connector-color-activeSets the color of a connector behind a reached step, current, completed, or error
--stepper-connector-thicknessSets the thickness of a connector
--stepper-indicator-sizeSets the diameter of a step's indicator
--stepper-indicator-border-radiusSets the corner radius of a step's indicator, such as a larger value for a rounded square instead of a circle
--stepper-indicator-backgroundSets the background color of an upcoming step's indicator
--stepper-indicator-colorSets the text color of an upcoming step's indicator
--stepper-indicator-background-currentSets the background color of the current step's indicator
--stepper-indicator-color-currentSets the text color of the current step's indicator
--stepper-indicator-background-completedSets the background color of a completed step's indicator
--stepper-indicator-color-completedSets the text color of a completed step's indicator
--stepper-indicator-background-errorSets the background color of a step's indicator whose state is error
--stepper-indicator-color-errorSets the text color of a step's indicator whose state is error
--stepper-indicator-font-sizeSets the font size of a step's number
--stepper-indicator-font-weightSets the font weight of a step's number
--stepper-label-colorSets the color of a step's label
--stepper-label-color-currentSets the color of the current step's label
--stepper-label-font-sizeSets the font size of a step's label
--stepper-label-font-weightSets the font weight of a step's label
--stepper-description-colorSets the color of a step's description
--stepper-description-font-sizeSets the font size of a step's description
--stepper-disabled-colorSets the text color of an unreachable, disabled step
--stepper-focus-outline-colorSets the color of a step's focus outline
--stepper-substep-ring-thicknessSets the thickness of the ring wrapped around the current step's indicator, summarizing substep progress
--stepper-substep-ring-fill-colorSets the color of the filled portion of the substep ring
--stepper-substep-ring-track-colorSets the color of the unfilled portion of the substep ring
--stepper-substep-ring-error-colorSets the fill color of the substep ring once the current substep's state is error
--stepper-transition-durationSets the duration of the indicator's background and color transitions

Basic

Leaving active-step unset defaults to 0, the first step.

<eun-stepper
  active-step="0"
  steps='[{"label":"Account"},{"label":"Profile"},{"label":"Confirm"}]'
></eun-stepper>

Descriptions

Add description to a step for a short sub-label underneath. The indicator centers on a label-only step (there's nothing to "start-align" against with a single line), then shifts to line up with the label specifically, rather than the midpoint of both lines, the moment a description adds a second one, as it does on "Cart" and "Shipping" below (compare against "Payment", label-only).

<eun-stepper
  active-step="1"
  steps='[
    {"label":"Cart","description":"3 items"},
    {"label":"Shipping","description":"Delivery address"},
    {"label":"Payment"}
  ]'
></eun-stepper>

Vertical orientation

orientation="vertical" stacks steps top to bottom, which suits a sidebar well. Not available on variant="progress" (see the Overview tab).

<eun-stepper
  orientation="vertical"
  active-step="1"
  steps='[
    {"label":"Cart","description":"3 items, $84.00"},
    {"label":"Shipping","description":"Delivery address"},
    {"label":"Payment","description":"Card or transfer"}
  ]'
></eun-stepper>

Compact display

Set the --stepper-mobile custom property to true, whether on the stepper itself or on any ancestor, since it inherits like any custom property, to hide labels and descriptions, leaving just the connected circles. It's read through a container style query (@container stepper style(--stepper-mobile: true)), not a size query, since the stepper never inspects its own rendered width, so the decision is entirely yours, usually made with a media query on some shared ancestor that matches your app's own breakpoints rather than a fixed pixel value baked into the component. See the Accessibility tab for why labels stay accessible to screen readers even while visually hidden this way.

/* Typically a breakpoint on a shared ancestor, e.g. your app shell : */
@media (max-width: 480px) {
  body {
    --stepper-mobile: true;
  }
}

/* Or forced unconditionally on one instance : */
.stepper-compact-demo {
  --stepper-mobile: true;
}
<eun-stepper
  class="stepper-compact-demo"
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping","description":"Delivery address"},{"label":"Payment"}]'
></eun-stepper>

Substeps

A step's own substeps show up as a thin ring wrapped around its indicator, but only while that step is the active one, filled clockwise in proportion to active-substep (empty on the first substep, fully filled on the last). It's a progress summary, not a set of individually clickable controls, so advance active-substep yourself, the same way activeStep itself is driven (see "Driving it with Next and Back buttons" below).

<!-- Ring at 50% : on the first of two substeps -->
<eun-stepper
  active-step="1"
  active-substep="0"
  steps='[
    {"label":"Cart"},
    {"label":"Shipping","substeps":[{"label":"Address"},{"label":"Delivery method"}]},
    {"label":"Payment"}
  ]'
></eun-stepper>

<!-- Ring fully filled : on the last of two substeps -->
<eun-stepper
  active-step="1"
  active-substep="1"
  steps='[
    {"label":"Cart"},
    {"label":"Shipping","substeps":[{"label":"Address"},{"label":"Delivery method"}]},
    {"label":"Payment"}
  ]'
></eun-stepper>

Slotted items

steps covers the common case, but a step's label can also need more than plain text, such as an icon, a badge, or rich markup. Slot eun-step elements in instead (the numbered display only, since variant="progress" always reads from steps): the moment any are present, they take over entirely and steps is ignored. eun-stepper manages each one's status/reachability directly, mirroring eun-tab-group setting active on its own eun-tab children, though a step's own disabled and state still work exactly like their data-driven equivalents, and a slot="description" mirrors description.

Cart Shipping Delivery address Payment
<eun-stepper active-step="1">
  <eun-step>Cart</eun-step>
  <eun-step>
    Shipping
    <span slot="description">Delivery address</span>
  </eun-step>
  <eun-step>Payment</eun-step>
</eun-stepper>

Activating a reachable eun-step dispatches the exact same eunstepchange on the eun-stepper itself, with the step's resolved index, so it's indistinguishable from the data-driven case on the receiving end:

document
  .querySelector("#slotted-demo")
  .addEventListener("eunstepchange", (event) => {
    event.target.activeStep = event.step;
  });

Error state

Set a step's (or substep's) own state to "error" to flag it once it's behind activeStep, e.g. a payment that failed and needs revisiting.

<eun-stepper
  active-step="2"
  steps='[
    {"label":"Cart"},
    {"label":"Shipping"},
    {"label":"Payment","state":"error","description":"Card declined"}
  ]'
></eun-stepper>

Linear vs. non-linear navigation

By default (linear, the default), only the current and already-visited steps are clickable, since skipping ahead of where the process actually is isn't offered at all. Set linear="false" to let every non-disabled step be activated directly, in any order. Both demos below are wired to their own eunstepchange, so click around to feel the difference: on the first, only Cart (already visited) responds, while on the second, every step does, including jumping straight to Payment.

<!-- linear (the default) : Payment (step 2) can't be clicked directly -->
<eun-stepper
  id="linear-demo"
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>

<!-- linear="false" : every step is directly reachable -->
<eun-stepper
  id="non-linear-demo"
  linear="false"
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>
for (const id of ["linear-demo", "non-linear-demo"]) {
  const stepper = document.querySelector(`#${id}`);
  stepper.addEventListener("eunstepchange", (event) => {
    stepper.activeStep = event.step;
  });
}

Blocking navigation on a specific step

A step's own disabled locks it regardless of linear, so set it from a condition your app already knows about, e.g. gating "Payment" until the cart actually has items. Combined with linear="false" here, Cart and Shipping are reachable in either order, but Payment stays blocked until you clear its own disabled yourself.

<eun-stepper
  id="blocking-demo"
  linear="false"
  active-step="0"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment","disabled":true}]'
></eun-stepper>
const stepper = document.querySelector("#blocking-demo");

stepper.addEventListener("eunstepchange", (event) => {
  stepper.activeStep = event.step;
});

// Once the condition that justified locking it is actually met :
function unlockPayment() {
  stepper.steps = stepper.steps.map((step) =>
    step.label === "Payment" ? { ...step, disabled: false } : step,
  );
}

Disabled (read-only)

disabled on the stepper itself disables every step's control, the same way an individual step's own disabled does, leaving a read-only progress summary with no navigation at all.

<eun-stepper
  disabled
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>

Progress variant

variant="progress" renders a single bar (an internal eun-progress-bar) instead, and substeps and orientation are both ignored.

<eun-stepper
  variant="progress"
  active-step="1"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>

Driving it with Next and Back buttons

The stepper itself never advances activeStep, since a real wizard almost always drives it from its own Next/Back controls instead of (or alongside) letting someone click a step directly, validating before moving forward.

Back Next
<eun-stepper
  id="wizard-stepper"
  active-step="0"
  steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>
<button id="back">Back</button>
<button id="next">Next</button>
const stepper = document.querySelector("#wizard-stepper");
const lastStep = stepper.steps.length - 1;

document.querySelector("#back").addEventListener("click", () => {
  stepper.activeStep = Math.max(0, stepper.activeStep - 1);
});

document.querySelector("#next").addEventListener("click", () => {
  // Validate whatever the current step needs before actually advancing.
  stepper.activeStep = Math.min(lastStep, stepper.activeStep + 1);
});

// Direct clicks on an already-visited step still work, the same way.
stepper.addEventListener("eunstepchange", (event) => {
  stepper.activeStep = event.step;
});

Custom

Override the --stepper-* CSS variables, listed in full in the API tab.

<eun-stepper class="custom-stepper" active-step="1" steps="..."></eun-stepper>
.custom-stepper {
  --stepper-indicator-background-current: #be185d;
  --stepper-indicator-background-completed: #be185d;
  --stepper-connector-color-active: #be185d;
  --stepper-label-color-current: #be185d;
  --stepper-indicator-border-radius: 6px;
}

Keyboard interactions

eun-stepper adds no custom keyboard handling of its own, since every reachable step is a native <button> (or, for the current one, plain non-interactive text), so standard button/focus behavior already applies. As with eun-pagination, there's no dedicated "stepper" pattern in the WAI-ARIA APG requiring roving tabindex or arrow-key navigation between steps, so this deliberately stays a plain sequence of independent buttons rather than a composite widget:

Key Action
Tab / Shift+Tab Moves focus between reachable steps, in visual order
Enter / Space Activates the focused step

Aria attributes and rules

  • Rendered as <nav aria-label="Progress"> wrapping an <ol role="list">, the same landmark-plus-list structure as eun-pagination/eun-breadcrumb. Override aria-label for other languages/contexts.
  • role="list" is set explicitly even though <ol> has it implicitly, since Safari drops the implicit role once list-style: none is applied (needed here for the flex layout), which would otherwise silently skip the list for VoiceOver users. The connectors between steps are separate <li aria-hidden="true"> entries in the same list, kept purely visual and removed from the accessibility tree entirely rather than being dead, unlabeled stops.
  • The current step renders as non-interactive text with aria-current="step", never a button, mirroring eun-pagination's current page, since activating the position someone's already on is a confusing, redundant control.
  • An unreachable step, whether ahead of activeStep while linear (the default), individually disabled, or every step while disabled is set on the stepper itself, uses the native disabled attribute on its <button> rather than aria-disabled, so it's properly excluded from the tab order and announced as unavailable, not just visually dimmed.
  • A completed step's check glyph and an error step's error glyph (both eun-icon, aria-hidden) are purely decorative, since the step's status is conveyed by its position relative to activeStep and, for an error, its visible label/description, not by the glyph alone. Always pair state="error" with a description (or your own surrounding text) explaining what needs attention.
  • The current step's substep-progress ring is likewise aria-hidden, since it's a purely visual gauge rather than something with a meaningful standalone accessible name. The equivalent information reaches assistive technology as visually-hidden text appended to the current step's own accessible name instead (e.g. "Shipping, step 2 of 3: Delivery method"), following the step's visible label rather than replacing it.
  • variant="progress" renders a real <meter> (via the internal eun-progress-bar), which exposes its value/min/max to assistive technology natively, requiring no manual aria-valuenow/aria-valuemin/aria-valuemax of its own. Its aria-label defaults to a computed "Step X of Y: <label>", overridable by setting aria-label on the eun-stepper itself.
  • Slotted eun-step elements set role="listitem" on themselves, since a custom element has no implicit ARIA role of its own, unlike the <li> a data-driven step renders as. Everything else above (aria-current, native disabled, decorative glyphs) applies identically either way, since a eun-step renders the exact same internal structure.

Hiding labels without hiding them from screen readers

--stepper-mobile (see "Compact display" in the Examples tab) visually hides each step's label and description, but never with display: none, since that would strip it from the accessibility tree too, leaving an unreachable step's <button> with no accessible name at all: an empty, silent control. Instead, the same visually-hidden-but-still-announced technique screen-reader-only text conventionally uses (off-screen positioning, not display/visibility) keeps every step's name intact for assistive technology regardless of whether it's visually showing.

No real links, on purpose

Every control is a <button>, never an <a href>. Like eun-pagination, eun-stepper never performs (or lets a router intercept) a native navigation. Steps are almost always gated by validation rather than individually linkable, so there's no href to meaningfully build in the first place. Keep a URL in sync yourself from eunstepchange instead, if your steps genuinely are addressable pages.

Reference links

WAI-ARIA Authoring Practices: Patterns
WAI-ARIA Authoring Practices: Landmark Regions