Primitives Feedback
Spinner
A busy indicator: an element with role="status" holding a text label, and a ring drawn in CSS. The label is what assistive technology reads; the ring turns, and turns slower when the reader prefers reduced motion, so it never reads as a stuck page. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
role="status",rotate,color-mix(),prefers-reduced-motion- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
role="status" with a text label
On this page
Example
<span data-slean="spinner" data-size="sm" role="status">
<span data-slean-part="label">Loading the comments</span>
</span>
<span data-slean="spinner" role="status">
<span data-slean-part="label">Loading the results</span>
</span>
<span data-slean="spinner" data-size="lg" role="status">
<span data-slean-part="label">Loading the report</span>
</span><script lang="ts">
// Nothing to import at runtime: the ring is CSS. The stylesheet is optional.
import '@svelte-lean/styles/spinner.css';
</script>
<span data-slean="spinner" data-size="sm" role="status">
<span data-slean-part="label">Loading the comments</span>
</span>
<span data-slean="spinner" role="status">
<span data-slean-part="label">Loading the results</span>
</span>
<span data-slean="spinner" data-size="lg" role="status">
<span data-slean-part="label">Loading the report</span>
</span>Why this implementation exists
A spinner is a picture of waiting, and the picture is the part a screen reader cannot use. What matters is the text: a label that says what is loading, inside a status region that announces it.
Svelte Lean ships a ring drawn from one element’s border, a label part the stylesheet hides visually, and a rule for the announcement: an empty spinner draws nothing, so it can wait in the page and show the ring when the application writes the label.
The browser owns
- the status role and handing a changed label to assistive technology
- running the CSS animation, and reporting the reduced motion preference
Svelte Lean owns
- spinner.css: the ring, its rotation from the duration tokens, the sizes
- the hidden label and the empty spinner that draws nothing
- the contract
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/spinner.css';Write what is loading in the label (“Loading the results”), not only “Loading”. Mark the region being loaded with aria-busy="true" until it is filled.
Inside a button, use the button’s own aria-busy="true" state instead: a status region inside a button adds its label to the button’s name.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <span data-slean="spinner" role="status"> | – | yes | The ring. data-size="sm|lg". Takes currentColor. |
| label | <span> with text | label | yes | Hidden visually, read by assistive technology. Without it the ring is not drawn. |
Runtime profile
Tier 0: the spinner has no behavior module, the Vite plugin maps spinner to no module, and the page ships no Svelte Lean JavaScript for it. The ring turns through a CSS animation.
Accessibility contract
role="status"with the label as text content: a live region announces its content, not anaria-label.- A spinner inserted together with its label may not be announced; keep it in the page and write the label when the wait begins.
- The label is clipped to one pixel and stays in the accessibility tree.
- Under reduced motion the indicator duration token (--slean-duration-indicator) grows instead of dropping to zero: the ring keeps turning, slower, and the label is unchanged.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | anywhere | Skips the spinner: it is not focusable |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
role="status" | ARIA 1.1 live region role, exposed by every current browser | Not applicable; speech timing differs between screen readers |
rotate | Baseline 2022 | The ring stands still |
color-mix() for the track | Baseline 2023 | The track uses the strong border token |
Without JavaScript
The ring is CSS and turns without JavaScript. Nothing ends the wait without the application’s script.
Server rendering
A page rendered in a loading state renders the spinner with its label; it is read in order and not announced. A spinner for a later wait is rendered empty.
Before hydration
Nothing is attached. An empty spinner rendered on the server is the status region the application writes into after hydration.
Styling
spinner.css draws the ring as the root’s border: a color-mix() tint of currentColor with the block-start side in currentColor, turned by a keyframe animation on the rotate property whose duration comes from the tokens. The spinner takes the text color around it. An empty spinner keeps its box and draws no ring. Under forced colors the moving side is CanvasText on a GrayText track. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-border-strong | var(--slean-neutral-8) | border |
--slean-duration-indicator | 750ms | animation |
State selectors the stylesheet targets, all from the platform or ARIA: :empty.
Variant attributes: data-size (sm, lg).
Compatibility notes
role="status" is an ARIA 1.1 live region role, exposed by every current browser; screen readers differ in when they speak a change. The rotate property is Baseline 2022 and color-mix() Baseline 2023; without the first the ring stands still, without the second the track uses the strong border token.
Examples
Announced wait
The spinner is in the page from the start and empty, so it draws nothing. Refreshing writes the label into it: the ring appears and a screen reader announces the label. When the wait ends the label is removed and the ring with it.
<script lang="ts">
let loading = $state(false);
async function refresh() {
loading = true;
await fetch('/api/results');
loading = false;
}
</script>
<button type="button" data-slean="button" onclick={refresh}>Refresh</button>
<!-- In the page from the start: empty, it draws nothing; the label brings the ring and is read. -->
<span data-slean="spinner" role="status">
{#if loading}<span data-slean-part="label">Loading the results</span>{/if}
</span>Testing
apps/playground/tests/primitives/feedback-navigation.spec.tsPlaywright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/spinner/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/spinner/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/spinner.cssthe optional stylesheet