Step Indicator
stimeo--step-indicator
Shows how far through a process you are. It is there to be read, not pressed.
Shows which stage of a process, such as a sign-up, you have reached. It exists to be read, so it takes no focus and cannot be pressed. Where you are is announced. Whether each stage is done, current or still to come is readable from CSS, so the colours and marks are yours. How far through you are is published as a fraction too, ready to draw a progress line. Moving between stages is up to the controls around it; when you need one you can press, use the stepper instead.
- Cart
- Shipping
- Payment
- Confirm
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the step-indicator (read-only progress) demo.
The library holds the current step number, sets data-state on each li, sets
aria-current="step" on the current li, and updates the progress CSS variable
(--stimeo--step-indicator-ratio). The steps themselves aren't interactive, so the
current step is updated via the external step:set event (fired by demo.js from the
prev/next buttons). %>
<div class="step-indicator-demo">
<%# --steps lets the CSS put the track between the first and last dot centres:
the library's ratio is "how far along the range", and the range runs from
centre to centre, not edge to edge. %>
<ol
class="step-indicator"
style="--steps: 4"
data-controller="stimeo--step-indicator"
aria-label="<%= t("components.step_indicator.demo.label") %>"
data-stimeo--step-indicator-current-value="1"
data-action="step:set->stimeo--step-indicator#setCurrent">
<% %w[cart shipping payment confirm].each_with_index do |step, index| %>
<li
class="step-indicator__step"
data-stimeo--step-indicator-target="step"
<%= "data-state=\"complete\"".html_safe if index < 1 %>
<%= "data-state=\"current\" aria-current=\"step\"".html_safe if index == 1 %>
<%= "data-state=\"upcoming\"".html_safe if index > 1 %>>
<span class="step-indicator__dot"></span>
<span class="step-indicator__label"><%= t(
"components.step_indicator.demo.steps.#{step}"
) %></span>
</li>
<% end %>
</ol>
<div class="step-indicator__controls">
<button type="button" class="demo-trigger" data-step-indicator-prev>
<%= t("components.step_indicator.demo.back") %>
</button>
<button type="button" class="demo-trigger" data-step-indicator-next>
<%= t("components.step_indicator.demo.advance") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the step-indicator demo.
* Complete / current / upcoming are built by reacting to each li's data-state. The
* progress bar uses --stimeo--step-indicator-ratio (0–1, updated by the library) as its width.
*
* The ratio measures how far along the *range* the current step is, and the range runs
* from the first dot's centre to the last one's — so the track is inset by half a step
* (--steps is the count the template renders), not by half a dot. Insetting by the dot
* instead leaves the fill short of the first dot and past the last one.
*/
.step-indicator {
display: flex;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
position: relative;
}
.step-indicator::before,
.step-indicator::after {
content: "";
position: absolute;
top: 0.5rem;
left: calc(100% / var(--steps, 2) / 2);
right: calc(100% / var(--steps, 2) / 2);
height: 2px;
background: var(--surface-subtle);
}
.step-indicator::after {
right: auto;
width: calc((100% - 100% / var(--steps, 2)) * var(--stimeo--step-indicator-ratio, 0));
background: var(--accent, var(--color-primary));
transition: width 0.2s ease;
}
.step-indicator__step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
font-size: 0.8125rem;
color: var(--color-text-subtle);
}
.step-indicator__dot {
position: relative;
z-index: 1;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: var(--border-strong);
}
.step-indicator__step[data-state="complete"] .step-indicator__dot,
.step-indicator__step[data-state="current"] .step-indicator__dot {
background: var(--accent, var(--color-primary));
}
.step-indicator__step[data-state="current"] {
color: var(--fg, var(--color-text));
font-weight: 600;
}
.step-indicator__controls {
display: flex;
gap: 0.5rem;
margin-top: 1.25rem;
}
// step-indicator external-update demo (consumer-side JS).
//
// The core controller (stimeo--step-indicator) is read-only: it only holds the current
// step and syncs data-state / aria-current / the progress CSS variable (no interaction).
// Changing the current step is done externally via the step:set event by contract, so
// here the prev/next buttons read the current value and fire step:set with a clamped new index.
document.querySelectorAll('[data-controller~="stimeo--step-indicator"]').forEach((list) => {
const root = list.closest('.step-indicator-demo') ?? list.parentElement;
if (!root) return;
const total = list.querySelectorAll('[data-stimeo--step-indicator-target="step"]').length;
const attr = 'data-stimeo--step-indicator-current-value';
const move = (delta) => {
const current = Number(list.getAttribute(attr) ?? 0);
const next = Math.min(total - 1, Math.max(0, current + delta));
list.dispatchEvent(new CustomEvent('step:set', { detail: { current: next } }));
};
root.querySelector('[data-step-indicator-prev]')?.addEventListener('click', () => move(-1));
root.querySelector('[data-step-indicator-next]')?.addEventListener('click', () => move(1));
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--step-indicator"
Targets
| Name | Description | Attribute |
|---|---|---|
step
required
|
Each step element, given a data-state (complete/current/upcoming) and aria-current. |
data-stimeo--step-indicator-target="step" |
Values
| Name | Description | Attribute |
|---|---|---|
current
|
The 0-based index of the current step, clamped to the step set (default 0). | data-stimeo--step-indicator-current-value |
Actions
| Name | Description | Action |
|---|---|---|
setCurrent
|
Updates the current step from detail.current (0-based, clamped) and dispatches change. | stimeo--step-indicator#setCurrent |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the current step changes, with detail.current and detail.total. | stimeo--step-indicator:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-state |
Step (li) | "complete" / "current" / "upcoming", derived from the current index. |
aria-current |
Current step (li) | "step" on the current step only. |
--stimeo--step-indicator-ratio |
Controller element | Overall progress as a 0–1 ratio for CSS. |