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
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
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).
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/stepper.js";
</script>
<eun-stepper
active-step="1"
steps='[{"label":"Cart"},{"label":"Shipping"},{"label":"Payment"}]'
></eun-stepper>
<script type="module">
const stepper = document.querySelector("eun-stepper");
stepper.addEventListener("eunstepchange", (event) => {
stepper.activeStep = event.step;
});
</script>
npm install @eunomia/elements
import { useState } from "react";
import "@eunomia/elements/stepper.js";
const steps = [{ label: "Cart" }, { label: "Shipping" }, { label: "Payment" }];
function Checkout() {
const [activeStep, setActiveStep] = useState(0);
return (
<eun-stepper
steps={JSON.stringify(steps)}
active-step={activeStep}
oneunstepchange={(event) => setActiveStep(event.step)}
/>
);
}
npm install @eunomia/elements
"use client";
import { useState } from "react";
import "@eunomia/elements/stepper.js";
const steps = [{ label: "Cart" }, { label: "Shipping" }, { label: "Payment" }];
export function Checkout() {
const [activeStep, setActiveStep] = useState(0);
return (
<eun-stepper
steps={JSON.stringify(steps)}
active-step={activeStep}
oneunstepchange={(event) => setActiveStep(event.step)}
/>
);
}
npm install @eunomia/elements
<script setup>
import { ref } from "vue";
import "@eunomia/elements/stepper.js";
const steps = [
{ label: "Cart" },
{ label: "Shipping" },
{ label: "Payment" },
];
const activeStep = ref(0);
</script>
<template>
<eun-stepper
:steps="JSON.stringify(steps)"
:active-step="activeStep"
@eunstepchange="activeStep = $event.step"
/>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/stepper.js";
@Component({
selector: "app-checkout",
template: `
<eun-stepper
[attr.steps]="stepsJson"
[attr.active-step]="activeStep"
(eunstepchange)="activeStep = $event.step"
></eun-stepper>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class CheckoutComponent {
steps = [{ label: "Cart" }, { label: "Shipping" }, { label: "Payment" }];
stepsJson = JSON.stringify(this.steps);
activeStep = 0;
}
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
eun-progress-bareun-tab-groupGuidance
- Keep the default
linearbehavior 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
activeStepforward only after validating the step being left, in your owneunstepchangehandler (or your own Next button's click handler, see the Examples tab) - Use
substepsonly 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-mobilewhen 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 withdisplay: noneyourself
- 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
progressvariant alone when the step names themselves matter to the person using it, given that it deliberately drops labels, descriptions and substeps - Wiring
orientation="vertical"ontovariant="progress", since the progress bar is always a single horizontal bar and ignores it entirely - Expecting the
substepsring 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
| Name | Type | Default | Description |
|---|---|---|---|
| steps | Array<EunomiaStepperStepData> | [] | The process's steps, in order. Ignored once any step children are slotted in |
| active-step | number | 0 | The zero-based index of the current step |
| active-substep | number | — | The zero-based index, into the active step's substeps, of the current substep. Meaningless when that step has no substeps |
| linear | boolean | true | Restricts direct navigation to steps before activeStep. Set to false to allow jumping to any non-disabled step in any order |
| aria-label | string | '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 |
| disabled | boolean | false | Disables 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
Slots
| Name | Description |
|---|---|
| (default) | Step elements, used instead of steps. Numbered display only |
Events
| Name | Type | Description |
|---|---|---|
| eunstepchange | StepChangeEvent | Fired 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
CSS custom properties
| Name | Description |
|---|---|
| --stepper-mobile | Set 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-gap | Sets the gap between a step's content and its neighboring connector |
| --stepper-connector-length | Sets 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-color | Sets the color of a connector ahead of a step that hasn't been reached yet |
| --stepper-connector-color-active | Sets the color of a connector behind a reached step, current, completed, or error |
| --stepper-connector-thickness | Sets the thickness of a connector |
| --stepper-indicator-size | Sets the diameter of a step's indicator |
| --stepper-indicator-border-radius | Sets the corner radius of a step's indicator, such as a larger value for a rounded square instead of a circle |
| --stepper-indicator-background | Sets the background color of an upcoming step's indicator |
| --stepper-indicator-color | Sets the text color of an upcoming step's indicator |
| --stepper-indicator-background-current | Sets the background color of the current step's indicator |
| --stepper-indicator-color-current | Sets the text color of the current step's indicator |
| --stepper-indicator-background-completed | Sets the background color of a completed step's indicator |
| --stepper-indicator-color-completed | Sets the text color of a completed step's indicator |
| --stepper-indicator-background-error | Sets the background color of a step's indicator whose state is error |
| --stepper-indicator-color-error | Sets the text color of a step's indicator whose state is error |
| --stepper-indicator-font-size | Sets the font size of a step's number |
| --stepper-indicator-font-weight | Sets the font weight of a step's number |
| --stepper-label-color | Sets the color of a step's label |
| --stepper-label-color-current | Sets the color of the current step's label |
| --stepper-label-font-size | Sets the font size of a step's label |
| --stepper-label-font-weight | Sets the font weight of a step's label |
| --stepper-description-color | Sets the color of a step's description |
| --stepper-description-font-size | Sets the font size of a step's description |
| --stepper-disabled-color | Sets the text color of an unreachable, disabled step |
| --stepper-focus-outline-color | Sets the color of a step's focus outline |
| --stepper-substep-ring-thickness | Sets the thickness of the ring wrapped around the current step's indicator, summarizing substep progress |
| --stepper-substep-ring-fill-color | Sets the color of the filled portion of the substep ring |
| --stepper-substep-ring-track-color | Sets the color of the unfilled portion of the substep ring |
| --stepper-substep-ring-error-color | Sets the fill color of the substep ring once the current substep's state is error |
| --stepper-transition-duration | Sets 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.
<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.
<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 aseun-pagination/eun-breadcrumb. Overridearia-labelfor other languages/contexts. role="list"is set explicitly even though<ol>has it implicitly, since Safari drops the implicit role oncelist-style: noneis 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, mirroringeun-pagination's current page, since activating the position someone's already on is a confusing, redundant control. - An unreachable step, whether ahead of
activeStepwhilelinear(the default), individuallydisabled, or every step whiledisabledis set on the stepper itself, uses the nativedisabledattribute on its<button>rather thanaria-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 toactiveStepand, for an error, its visible label/description, not by the glyph alone. Always pairstate="error"with adescription(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 internaleun-progress-bar), which exposes its value/min/max to assistive technology natively, requiring no manualaria-valuenow/aria-valuemin/aria-valuemaxof its own. Itsaria-labeldefaults to a computed "Step X of Y: <label>", overridable by settingaria-labelon theeun-stepperitself.- Slotted
eun-stepelements setrole="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, nativedisabled, decorative glyphs) applies identically either way, since aeun-steprenders 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