Spinner
Summary
The indeterminate loading indicator — an unknown wait with no
layout to hold. Pure CSS in three sizes, colored by tokens
(neutral ring, primary head), so it reads the same on
translucent and solid surfaces in every theme. Static markup
needs role="status" and an
aria-label so screen readers announce it; the
Soma.spinner() helper adds both for you and is
idempotent, so it's safe to call on every load start. Keeps
spinning under reduced motion (it's functional, not
decorative), just slower.
When to use
| Component | Use it for |
|---|---|
| Spinner | Short unknown waits with no layout to reserve: a button's pending action, a refreshing panel. |
| Skeleton | First paint of async views where the eventual layout is known. Hold its shapes instead of spinning. |
Progress -indeterminate | Unknown-duration work tied to a task the user is tracking — a sweeping bar reads as "working on it". |
| Progress | Known-fraction work: uploads, migrations. Always prefer determinate when you can compute it. |
Examples
HTML
Static markup, three sizes. role="status" +
aria-label are part of the contract, since a bare
spinner is invisible to assistive technology:
<span class="soma-spinner" role="status" aria-label="Loading"></span>
<span class="soma-spinner soma-spinner-small" role="status" aria-label="Loading"></span>
<span class="soma-spinner soma-spinner-large" role="status" aria-label="Loading"></span>
The pending-button composition: a small spinner inline with the label while the action runs. The spinner inherits nothing color-wise from the button (its ring and head are tokens), so it works on any button variant:
<button class="soma-button soma-button-primary" disabled>
<span class="soma-spinner soma-spinner-small" role="status" aria-label="Saving"></span>
Saving…
</button>
CSS classes
| Class | Effect |
|---|---|
.soma-spinner | The default-size (20px) spinner: a neutral token ring with a primary head, so it works on any surface without per-context tuning. Keeps spinning under reduced motion — slower (2s per revolution instead of 0.8s), because the rotation is the information. |
.soma-spinner-small / -large | Size variants: small (14px) for inline/button contexts, large (32px, thicker ring) for empty panels. |
JavaScript
Static markup needs no JS. The helper exists for the common
"show a spinner in this container while loading" flow — it
appends, labels, and never stacks. Unlike the component APIs it
returns the spinner element, not an instance; there is
nothing to destroy beyond spinnerStop().
Soma.spinner(target, options?)
| Member | Description |
|---|---|
Soma.spinner(elOrSelector, opts?) | Append one accessible spinner (role="status" + label) to the container and return the spinner element. Throws when the target matches nothing. Idempotent per container: it looks for an existing direct-child spinner first, so repeated calls reuse the same element. Nested containers each manage their own. |
opts.size | 'small' | 'default' | 'large'. Anything else falls back to default. A repeat call re-applies the size — the existing spinner just changes class. |
opts.label | The aria-label announced to screen readers. Defaults to the localised "Loading" (i18n key spinner.loading; follows Soma.i18n / <html lang>). Applied when the spinner is created; a repeat call on an existing spinner keeps its original label. |
Soma.spinnerStop(target)
| Member | Description |
|---|---|
Soma.spinnerStop(elOrSelector) | Remove any direct-child spinner the helper added to that container. Silently a no-op when there is none — and, unlike Soma.spinner(), also when the target matches nothing, so it's always safe in cleanup paths. |
The canonical loading flow starts the spinner before the
request and stops it in finally so errors never
strand it:
async function reload() {
Soma.spinner('#results', { size: 'large' });
try {
const rows = await fetchRows();
renderRows(rows); // replacing the container's content
} finally {
Soma.spinnerStop('#results'); // no-op if renderRows already replaced it
}
}
Name the specific wait. The label is what screen readers announce when the spinner appears:
Soma.spinner('#sessions-panel', { label: 'Loading sessions' });
Idempotency in practice: calling on every load start is the intended usage, and there is nothing to guard:
input.addEventListener('input', () => {
Soma.spinner('#results'); // 1st call appends, later calls reuse
scheduleSearch(input.value); // fires often; never stacks spinners
});
The live examples above, exactly as this page wires them:
document.getElementById('spin-small').addEventListener('click', () =>
Soma.spinner('#spin-target', { size: 'small' }));
document.getElementById('spin-large').addEventListener('click', () =>
Soma.spinner('#spin-target', { size: 'large' }));
document.getElementById('spin-stop').addEventListener('click', () =>
Soma.spinnerStop('#spin-target'));
document.getElementById('spin-labelled').addEventListener('click', () =>
Soma.spinner('#spin-label-target', { label: 'Loading sessions' }));
document.getElementById('flow-reload').addEventListener('click', async () => {
const target = document.getElementById('flow-target');
target.textContent = '';
Soma.spinner(target);
try {
await new Promise((resolve) => setTimeout(resolve, 1200)); // fake fetch
target.innerHTML = '<span>3 sessions active.</span>';
} finally {
Soma.spinnerStop(target);
}
});