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

ComponentUse it for
SpinnerShort unknown waits with no layout to reserve: a button's pending action, a refreshing panel.
SkeletonFirst paint of async views where the eventual layout is known. Hold its shapes instead of spinning.
Progress -indeterminateUnknown-duration work tied to a task the user is tracking — a sweeping bar reads as "working on it".
ProgressKnown-fraction work: uploads, migrations. Always prefer determinate when you can compute it.

Examples

Sizes

-small 14 · default 20 · -large 32:

Small for inline/button contexts, large for empty panels.

On a solid surface

soma-widget-solid, an opaque card:

Refreshing sessions…

The ring and head come from --soma-* tokens, so contrast holds on the opaque widget as well as the default translucent one, in every theme.

JS helper

Idempotent per container:

Clicking either button repeatedly never stacks spinners — the one spinner just changes size.

Custom label

The label option names the specific wait:

This one announces "Loading sessions" instead of the localised default "Loading". Inspect the appended span's aria-label.

Loading flow

spinner() on start, spinnerStop() in finally:

3 sessions active.

Click reloads: the content clears, a spinner holds the wait, then the result replaces it. The stop call lives in finally, so a failed load never strands the spinner.

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

ClassEffect
.soma-spinnerThe 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 / -largeSize 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?)

MemberDescription
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.labelThe 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)

MemberDescription
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);
  }
});