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 position, 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
position is moved from the outside: a stepper sits inside and dispatches
stimeo--stepper:change with { index, previous, total, step }, which the indicator
receives through setIndex. No consumer JS is involved. %>
<div class="step-indicator-demo" data-controller="stimeo--stepper"
data-stimeo--stepper-index-value="1">
<%# --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-index-value="1"
data-action="stimeo--stepper:change@window->stimeo--step-indicator#setIndex">
<% %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>
<%# The stepper owns the position; its own steps stay visually hidden because the
indicator above is what this demo shows. %>
<ol class="step-indicator__driver">
<% %w[cart shipping payment confirm].each_with_index do |step, index| %>
<li data-stimeo--stepper-target="step">
<button
type="button"
class="demo-trigger"
data-stimeo--stepper-index-param="<%= index %>"
data-action="click->stimeo--stepper#goto">
<%= t("components.step_indicator.demo.steps.#{step}") %>
</button>
</li>
<% end %>
</ol>
<div class="step-indicator__controls">
<button type="button" class="demo-trigger" data-action="click->stimeo--stepper#prev">
<%= t("components.step_indicator.demo.back") %>
</button>
<button type="button" class="demo-trigger" data-action="click->stimeo--stepper#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;
}
/* The stepper that drives the indicator is a plain row of buttons: the indicator
above is what this demo is about, so its own list carries no markers. */
.step-indicator__driver {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
}
This demo needs no consumer-side JS (the controller handles the behavior).
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 |
|---|---|---|
index
|
The 0-based position of the current step, clamped to the step set (default 0). | data-stimeo--step-indicator-index-value |
Actions
| Name | Description | Action |
|---|---|---|
setIndex
|
Moves the indicator from detail.index (0-based, clamped) and dispatches change. | stimeo--step-indicator#setIndex |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the position moves, with detail.index, detail.previous 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. |