# Spinner (Moonarc)

An indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge. One SVG circle, pure CSS; announces itself as a status.

- Import: `import Spinner from '@moonarc/core/Spinner'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/spinner.json`
- Tier A · category loading · trigger always
- Readout: `<Spinner size={20}>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 4.8 kB raw)
- Page: https://moonarc.dev/components/spinner/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` | `number` | `20` | Diameter in px. |
| `width` | `number` | `2` | Stroke width in px. |
| `color` | `string` | `'currentColor'` | Colour. |
| `label` | `string` | `'Loading'` | Accessible label. |
| `duration` | `number` | `1.2` | Seconds per rotation. |

## Usage

```astro
<button disabled><Spinner size={16} /> Saving…</button>
```

## Reduced motion

A fixed arc rotating at less than half speed, still readable as loading but no longer busy.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Rotation is linear; the dash length is ease-in-out on a different period, so the arc never repeats the same shape at the same angle.
- stroke-linecap: round, because a square-ended arc reads as broken.
- role="status" with a label: the spinner is announced once, not on every frame.

## Replaces

- Spinner (shadcn)
- react-spinners

## Source

```astro
---
/**
 * Spinner — an arc that chases its tail, zero JS. One SVG circle: the dash
 * length breathes while the whole thing rotates, so the arc grows and
 * shrinks like a real indeterminate indicator, not a fixed wedge.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Diameter in px. */
  size?: number;
  /** Stroke width in px. */
  width?: number;
  /** Colour. */
  color?: string;
  /** Accessible label. */
  label?: string;
  /** Seconds per rotation. */
  duration?: number;
}

const { size = 20, width = 2, color = 'currentColor', label = 'Loading', duration = 1.2, class: className, ...rest } = Astro.props;
---

<span class:list={['ma-spinner', className]} role="status" aria-label={label} style={`--ma-spin-size:${size}px;--ma-spin-w:${width}px;--ma-spin-color:${color};--ma-spin-dur:${duration}s`} {...rest}>
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-linecap="round" />
  </svg>
</span>

<style is:global>
  @layer components {
    :where(.ma-spinner) {
      display: inline-block;
      width: var(--ma-spin-size, 20px);
      height: var(--ma-spin-size, 20px);
      color: var(--ma-spin-color, currentColor);
      vertical-align: middle;
    }
    :where(.ma-spinner svg) {
      display: block;
      width: 100%;
      height: 100%;
      animation: ma-spinner-rotate var(--ma-spin-dur, 1.2s) linear infinite;
    }
    :where(.ma-spinner circle) {
      stroke-width: var(--ma-spin-w, 2px);
      stroke-dasharray: 63;
      stroke-dashoffset: 47;
      animation: ma-spinner-dash calc(var(--ma-spin-dur, 1.2s) * 1.5) var(--ma-ease-in-out) infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-spinner circle) {
        animation: none;
        stroke-dashoffset: 47;
      }
      :where(.ma-spinner svg) {
        animation-duration: calc(var(--ma-spin-dur, 1.2s) * 2.5);
      }
    }
  }

  @keyframes ma-spinner-rotate {
    to {
      rotate: 360deg;
    }
  }
  @keyframes ma-spinner-dash {
    0% {
      stroke-dashoffset: 56;
    }
    50% {
      stroke-dashoffset: 16;
    }
    100% {
      stroke-dashoffset: 56;
    }
  }
</style>

```
