Spinner
stimeo--spinner
Toggles a loading indicator with a live region, aria-busy, and anti-flicker timers.
The stimeo--spinner controller follows the live-region + aria-busy practice. start / stop toggle a role="status" indicator that carries text (never an icon alone) so screen readers announce loading, and mirror the busy state onto the controlled region via aria-busy. Two timers tame flicker: delay suppresses the spinner for operations that finish quickly, and minDuration keeps it visible long enough to be perceived once shown. It dispatches stimeo--spinner:show / :hide, and both timers are torn down on disconnect (Turbo included). Behavior only — the visual spinner is owned by this Playground.
“Start loading” enters the loading state and “Stop loading” ends it. To prevent flicker, the spinner appears after a ~150 ms delay and, once shown, stays for at least ~600 ms — so right after “Stop loading” it lingers briefly before hiding. That delay is intentional, not a stuck spinner.
<%# Markup for the spinner (loading indicator) demo.
The indicator holds text in a role="status" live region, with the visual spinner
placed alongside as aria-hidden="true". The region reflects aria-busy. A show delay
and a minimum display time suppress flicker. The Start/Stop buttons call the
controller's methods directly. %>
<div
class="spinner-demo"
data-controller="stimeo--spinner"
data-stimeo--spinner-delay-value="150"
data-stimeo--spinner-min-duration-value="600">
<div class="spinner-demo__controls">
<button
class="demo-trigger"
type="button"
data-action="click->stimeo--spinner#start">
<%= t("components.spinner.demo.start") %>
</button>
<button
class="demo-trigger"
type="button"
data-action="click->stimeo--spinner#stop">
<%= t("components.spinner.demo.stop") %>
</button>
</div>
<p class="spinner-demo__hint"><%= t("components.spinner.demo.hint") %></p>
<div
class="spinner"
role="status"
aria-live="polite"
hidden
data-stimeo--spinner-target="indicator">
<span class="spinner__icon" aria-hidden="true"></span>
<span data-stimeo--spinner-target="message"><%= t("components.spinner.demo.loading") %></span>
</div>
<div
class="spinner-demo__region"
aria-busy="false"
data-stimeo--spinner-target="region">
<%= t("components.spinner.demo.content") %>
</div>
</div>
/*
* Presentation-only styles for the spinner demo.
* This CSS owns the visual spinner's rotation; the library only toggles
* hidden / aria-busy / data-state (idle / pending / loading).
*/
.spinner-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.spinner-demo__controls {
display: flex;
gap: 0.5rem;
}
/* Explanatory caption: clarifies the intentional delay / minimum-display timing. */
.spinner-demo__hint {
margin: 0;
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.spinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: var(--fg);
}
.spinner__icon {
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spinner-rotate 0.7s linear infinite;
}
@keyframes spinner-rotate {
to {
transform: rotate(360deg);
}
}
.spinner-demo__region {
min-height: 3rem;
padding: 0.75rem 1rem;
border: 1px dashed var(--border);
border-radius: 0.375rem;
color: var(--fg);
}
/* Dim the target region while loading so the visual matches aria-busy. */
.spinner-demo__region[aria-busy="true"] {
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.spinner__icon {
animation: 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--spinner"
Targets
| Name | Description | Attribute |
|---|---|---|
indicator
|
The role="status" live region (carrying text) shown/hidden as the spinner. |
data-stimeo--spinner-target="indicator" |
region
|
The controlled region whose aria-busy mirrors the loading state. |
data-stimeo--spinner-target="region" |
message
|
The text element inside the indicator announced by screen readers. | data-stimeo--spinner-target="message" |
Values
| Name | Description | Attribute |
|---|---|---|
delay
|
Milliseconds to suppress the spinner after start, hiding it for fast operations (default 0). | data-stimeo--spinner-delay-value |
minDuration
|
Minimum milliseconds the shown spinner stays visible to avoid flicker (default 0). | data-stimeo--spinner-min-duration-value |
Actions
| Name | Description | Action |
|---|---|---|
start
|
Begins loading, marking busy and showing the spinner after delay. | stimeo--spinner#start |
stop
|
Ends loading, clearing busy and hiding the spinner after minDuration. |
stimeo--spinner#stop |
Events
| Name | Description | Event |
|---|---|---|
hide
|
Fires when the spinner is hidden and the state returns to idle. | stimeo--spinner:hide |
show
|
Fires when the spinner becomes visible. | stimeo--spinner:show |
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 |
|---|---|---|
hidden |
Indicator | Present while hidden; removed once the spinner shows. |
aria-busy |
Region | "true" while loading. |
data-state |
Root element | "idle" / "pending" (awaiting delay) / "loading". |