Progress
stimeo--progress
Syncs a progressbar's ARIA value attributes and exposes the ratio for the bar.
The stimeo--progress controller drives the WAI-ARIA progressbar role. It normalizes the value into [min, max], keeps aria-valuenow / aria-valuemin / aria-valuemax in sync, and exposes the fraction as the --stimeo-progress-ratio (0–1) custom property your CSS turns into a width. The indeterminate state drops aria-valuenow and flips data-state="indeterminate" for a looping animation. setValue accepts an amount action param or a progress:set event detail, and the controller dispatches stimeo--progress:change and :complete. Behavior only — the bar's look is owned by this Playground.
<%# Markup for the progress (progress bar) demo.
The library syncs role="progressbar" and aria-value*, and exposes the ratio as
--stimeo-progress-ratio (0–1). The bar's width and color live in demo.css.
The control buttons sit outside the progressbar (a progressbar must not contain
interactive children), so they can't drive it through a Stimulus data-action —
that only binds inside the controller's own element. Instead they dispatch the
progress:set CustomEvent the controller listens for (see demo.js), the same
contract a consumer would use to update progress from elsewhere. %>
<div class="progress-demo">
<div
class="progress"
data-controller="stimeo--progress"
role="progressbar"
aria-label="<%= t('components.progress.demo.label') %>"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="40"
data-stimeo--progress-value-value="40"
data-stimeo--progress-value-text-value="<%= t('components.progress.demo.value_text') %>"
data-action="progress:set->stimeo--progress#setValue">
<div class="progress__bar" data-stimeo--progress-target="bar"></div>
</div>
<div
class="progress-demo__controls"
role="group"
aria-label="<%= t('components.progress.demo.controls_label') %>">
<% [0, 25, 50, 75, 100].each do |amount| %>
<button
class="demo-trigger"
type="button"
data-progress-amount="<%= amount %>">
<%= amount %>%
</button>
<% end %>
</div>
<%# Indeterminate example: drop aria-valuenow and let the consumer apply a looping
animation via data-state="indeterminate". %>
<div
class="progress progress--indeterminate"
data-controller="stimeo--progress"
role="progressbar"
aria-label="<%= t('components.progress.demo.indeterminate_label') %>"
aria-valuemin="0"
aria-valuemax="100"
data-stimeo--progress-indeterminate-value="true">
<div class="progress__bar" data-stimeo--progress-target="bar"></div>
</div>
</div>
/*
* Presentation-only styles for the progress demo.
* The bar width is derived from --stimeo-progress-ratio (0–1) exposed by the
* library; the indeterminate state is detected via data-state="indeterminate" to
* apply a looping animation.
*/
.progress-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.progress {
height: 0.75rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-subtle);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: inherit;
background: var(--accent);
/* Convert the 0–1 ratio into a percentage width. */
width: calc(var(--stimeo-progress-ratio, 0) * 100%);
transition: width 0.25s ease;
}
/* Indeterminate: fix the width and slide it back and forth to signal "working". */
.progress--indeterminate[data-state="indeterminate"] .progress__bar {
width: 40%;
animation: progress-indeterminate 1.2s ease-in-out infinite;
}
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
.progress-demo__controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (prefers-reduced-motion: reduce) {
.progress__bar {
transition: none;
}
.progress--indeterminate[data-state="indeterminate"] .progress__bar {
animation: none;
}
}
// progress external-update demo (consumer-side JS).
//
// The core controller (stimeo--progress) syncs the ARIA value attributes and the
// --stimeo-progress-ratio custom property; it does not own any buttons. A
// progressbar must not contain interactive children, so the preset buttons live
// next to it rather than inside it — which means a Stimulus data-action on them
// would never bind (actions only wire up within the controller's own element).
// The contract for updating from outside is the progress:set event, so here each
// button fires progress:set with its target value on the progressbar element.
document.querySelectorAll(".progress-demo").forEach((root) => {
const bar = root.querySelector(
'.progress:not(.progress--indeterminate)[data-controller~="stimeo--progress"]',
);
const controls = root.querySelector(".progress-demo__controls");
if (!bar || !controls) return;
controls.querySelectorAll("[data-progress-amount]").forEach((button) => {
button.addEventListener("click", () => {
const value = Number(button.getAttribute("data-progress-amount"));
bar.dispatchEvent(new CustomEvent("progress:set", { detail: { value } }));
});
});
});
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--progress"
Targets
| Name | Description | Attribute |
|---|---|---|
bar
|
The fill element the consumer drives off the ratio custom property and data-state. |
data-stimeo--progress-target="bar" |
Values
| Name | Description | Attribute |
|---|---|---|
value
|
Current progress value, clamped into [min, max] (default 0). | data-stimeo--progress-value-value |
min
|
Lower bound of the range, reflected to aria-valuemin (default 0). |
data-stimeo--progress-min-value |
max
|
Upper bound of the range, reflected to aria-valuemax (default 100). |
data-stimeo--progress-max-value |
indeterminate
|
Whether the bar is indeterminate; drops aria-valuenow and sets data-state="indeterminate" (default false). |
data-stimeo--progress-indeterminate-value |
valueText
|
Template for aria-valuetext, substituting {value}/{percent}; empty clears it. |
data-stimeo--progress-value-text-value |
Actions
| Name | Description | Action |
|---|---|---|
setValue
|
Sets the value from an action param (amount) or detail.value, clears indeterminate, and syncs ARIA. | stimeo--progress#setValue |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on every value update with detail.value and detail.ratio. | stimeo--progress:change |
complete
|
Fires when the value reaches max, with detail.value. | stimeo--progress:complete |
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 |
|---|---|---|
aria-valuenow |
Root element | The current value; removed while indeterminate. |
--stimeo-progress-ratio |
Root element | The 0–1 fraction your CSS turns into the bar width. |
data-state |
Root element | "determinate" / "indeterminate". |