Skip to content

Components / Loading

Spinner

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Spinner from '@moonarc/core/Spinner';
---
<button disabled><Spinner size={16} /> Saving…</button>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/spinner.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Spinner.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>

Props

PropTypeDefaultDescription
sizenumber20Diameter in px.
widthnumber2Stroke width in px.
colorstring'currentColor'Colour.
labelstring'Loading'Accessible label.
durationnumber1.2Seconds per rotation.

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.

Why it is built this way

Replaces: Spinner (shadcn) · react-spinners. See the migration table.

Progress

Loading

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Ripple

Backgrounds

Rings expand from a point and fade, one after another, like a pulse.

every browseralways

Skeleton

Loading

Placeholder lines, optionally with an avatar, shimmering while content loads.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown