Spinner

Sizes

Muted

Inverse

Loading on dark surface

  
/*
 * Spinner — foundation
 *
 * Determinate-less loading indicator. CSS-only — no JavaScript.
 * Uses a single rotating ring; no DOM scaffolding required from the
 * runtime.
 *
 *   <span class="spinner" role="status" aria-label="Loading"></span>
 *   <span class="spinner -s" role="status" aria-label="Loading"></span>
 *   <span class="spinner -l" role="status" aria-label="Loading"></span>
 *   <span class="spinner -inverse" role="status" aria-label="Loading"></span>
 */

@layer malevich.components {
  .spinner {
    --spinner-size: var(--size-control-m);
    --spinner-track: var(--color-border-muted);
    --spinner-indicator: var(--color-accent);
    --spinner-thickness: var(--border-width-emphasis);
    --spinner-duration: 900ms;

    display: inline-block;
    inline-size: var(--spinner-size);
    block-size: var(--spinner-size);

    border-radius: 50%;
    border: var(--spinner-thickness) solid var(--spinner-track);
    border-top-color: var(--spinner-indicator);

    animation: malevich-spinner-rotate var(--spinner-duration) linear infinite;
    flex-shrink: 0;
  }

  .spinner.-s {
    --spinner-size: var(--size-control-s);
    --spinner-thickness: var(--border-width-focus);
  }

  .spinner.-l {
    --spinner-size: var(--size-control-l);
  }

  .spinner.-xl {
    --spinner-size: var(--size-control-xl);
  }

  .spinner.-inverse {
    --spinner-track: var(--color-border-on-inverse, var(--color-border-muted));
    --spinner-indicator: var(--color-ink-inverse);
  }

  .spinner.-muted {
    --spinner-indicator: var(--color-ink-subtle);
  }

  @keyframes malevich-spinner-rotate {
    to { transform: rotate(360deg); }
  }

  @media (prefers-reduced-motion: reduce) {
    .spinner {
      animation-duration: 2400ms;
    }
  }
}
This component is pure CSS — no JavaScript required.
# Spinner — AGENTS.md

> Auto-generated from `spinner.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `spinner`
- **Layer:** foundations
- **Status:** stable
- **Last updated:** 2026-05-18

## Summary

- Async operation in progress where the duration is unknown.

## Variants

| Variant     | Class                | Use for                                |
|-------------|----------------------|----------------------------------------|
| Default (m) | `.spinner`           | Standard size, accent color            |
| Small       | `.spinner.-s`        | Inline with body text, button icons    |
| Large       | `.spinner.-l`        | Section-level loading                  |
| Extra large | `.spinner.-xl`       | Centered page-level loading            |
| Inverse     | `.spinner.-inverse`  | On dark / accent backgrounds           |
| Muted       | `.spinner.-muted`    | De-emphasized loading                  |

Modifiers compose: `.spinner.-l.-inverse` is allowed.

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | — |
| `shader` | — |
| `rhythm` | — |
| `motion` | `none`, `standard` |
| `behavior` | — |

## Anatomy

```html
<span class="spinner" role="status" aria-label="Loading"></span>

<!-- Inside a button -->
<button class="button -primary" aria-busy="true">
  <span class="spinner -s -inverse" aria-hidden="true"></span>
  Submitting…
</button>

<!-- Centered -->
<div style="display:grid; place-items:center; min-height:200px;">
  <span class="spinner -xl" role="status" aria-label="Loading"></span>
</div>
```

## Tokens consumed

### From semantic tier
- `--color-border-muted` — track color
- `--color-accent` — indicator color
- `--color-ink-inverse` — inverse variant indicator
- `--color-ink-subtle` — muted variant indicator
- `--size-control-s` / `-m` / `-l` / `-xl` — diameter
- `--border-width-focus` — small variant thickness
- `--border-width-emphasis` — default/large thickness

### Component-tier (defined inline)
- `--spinner-size` — overridable diameter
- `--spinner-track` — overridable track color
- `--spinner-indicator` — overridable indicator color
- `--spinner-thickness` — overridable ring thickness

## Accessibility contract

The spinner element carries `role="status"` and `aria-label="Loading"`
(authored on the element). Screen readers announce the loading state
when the element appears in the DOM.

Inside a button, the spinner replaces the icon during `aria-busy="true"`
and should be marked `aria-hidden="true"`; the button's accessible name
(text or `aria-label`) carries the meaning. The button's `aria-busy`
attribute is the source of truth for assistive technology.

The component honors `prefers-reduced-motion: reduce` by slowing the
rotation from 900ms to 2400ms. It does not stop the animation
entirely because loading without visual feedback is worse for
recognition than a slowed spinner.

## Guidance

### Do

- Pair every spinner with `role="status"` and a label (or `aria-hidden`
  if a parent button carries the busy state).
- Use the small variant inside buttons.
- Reach for skeletons when loading layout, not just content.

### Don't

- Don't animate the spinner manually with JavaScript. The CSS keyframe
  handles motion.
- Don't disable the reduced-motion accommodation. Slower is fine; no
  motion at all hides progress.