Spinner
stimeo--spinner
Shows and hides a loading indicator, and says out loud when loading starts and ends.
Shows and hides a loading indicator, and reports that the region is busy to screen readers. The spinning shape is for the eyes, so starting and finishing are also said in words through the shared announcer, since text revealed at the moment of the change is not reliably read. Two touches keep it from flickering: a job that finishes quickly never shows one at all, and once shown it stays long enough to be seen. That is why it lingers briefly after you stop, which is correct rather than stuck. The spinning shape itself is drawn by this demo's CSS.
Start loading enters the loading state and Stop loading ends it. To avoid flicker the spinner appears after a short delay and, once shown, stays for a moment. Lingering briefly after you stop is intentional.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the spinner (loading indicator) demo.
The indicator is the visual half — its text plus a spinner marked aria-hidden="true" —
and the controlled region reflects aria-busy. An indicator revealed at the moment of
the change is not reliably read, so the loading / ready wording goes to the shared
stimeo--announcer your app seats once, in its layout, and is said exactly once
(loading ↔ ready is the transition worth reading; aria-busy is state the reader can
query, not a notification). 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"
data-stimeo--spinner-announce-text-value="<%= t("components.spinner.demo.loading") %>"
data-stimeo--spinner-announce-ready-text-value="<%=
t("components.spinner.demo.announce_ready") %>">
<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>
<%# Visual only: the announcer says this once, so a live region here would repeat it. %>
<div
class="spinner"
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 visual element (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 that names the loading state on screen. | data-stimeo--spinner-target="message" |
Values
| Name | Description | Attribute |
|---|---|---|
announceText
|
Wording read out when loading starts; empty announces nothing. | data-stimeo--spinner-announce-text-value |
announceReadyText
|
Wording read out when loading finishes; empty announces nothing. | data-stimeo--spinner-announce-ready-text-value |
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 |
timeout
|
data-stimeo--spinner-timeout-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 |
timeout
|
stimeo--spinner:timeout |
|
reconcile
|
Fires when the Turbo cache rewind returns a running cycle to idle; detail is {}. |
stimeo--spinner:reconcile |
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". |