Skip to content

Spinner

A pure-CSS indeterminate indicator that inherits currentColor and costs nothing to render.

Feedbackstarterloadingbusyindicator

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { DemoRow, DemoStage } from './_kit'

export default function SpinnerDemo() {
  return (
    <DemoStage>
      <DemoRow label="Sizes" description="Inherits currentColor, so it matches its context.">
        <Spinner size="xs" />
        <Spinner size="sm" />
        <Spinner size="md" />
        <Spinner size="lg" />
      </DemoRow>

      <DemoRow label="In context">
        <Spinner size="sm" label="Loading results" className="text-accent" />
        <span className="flex items-center gap-2 text-sm text-ink-muted">
          <Spinner size="sm" />
          Checking availability…
        </span>
        <Button loading loadingLabel="Publishing">
          Publish
        </Button>
      </DemoRow>

      <DemoRow label="On a filled surface">
        <span className="flex items-center gap-2 rounded-md bg-surface-inverse px-3 py-2 text-sm text-ink-inverse">
          <Spinner size="sm" />
          Provisioning
        </span>
      </DemoRow>
    </DemoStage>
  )
}

components/demos/spinner.tsx

import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { DemoRow, DemoStage } from './_kit'

export default function SpinnerDemo() {
  return (
    <DemoStage>
      <DemoRow label="Sizes" description="Inherits currentColor, so it matches its context.">
        <Spinner size="xs" />
        <Spinner size="sm" />
        <Spinner size="md" />
        <Spinner size="lg" />
      </DemoRow>

      <DemoRow label="In context">
        <Spinner size="sm" label="Loading results" className="text-accent" />
        <span className="flex items-center gap-2 text-sm text-ink-muted">
          <Spinner size="sm" />
          Checking availability…
        </span>
        <Button loading loadingLabel="Publishing">
          Publish
        </Button>
      </DemoRow>

      <DemoRow label="On a filled surface">
        <span className="flex items-center gap-2 rounded-md bg-surface-inverse px-3 py-2 text-sm text-ink-inverse">
          <Spinner size="sm" />
          Provisioning
        </span>
      </DemoRow>
    </DemoStage>
  )
}

components/ui/spinner.tsx

import { cn } from '@/lib/cn'

/**
 * Spinner
 *
 * A pure-CSS indeterminate indicator. Drawn with a border rather than an SVG
 * so it inherits `currentColor` and costs nothing to render. Under
 * `prefers-reduced-motion` the global reset stops the rotation, leaving a
 * static ring — which is why the accessible label lives on the wrapper.
 */
export interface SpinnerProps {
  size?: 'xs' | 'sm' | 'md' | 'lg'
  className?: string
  /** Omit for decorative use inside an already-labelled control. */
  label?: string
}

const sizeMap = {
  xs: 'size-3 border',
  sm: 'size-4 border-[1.5px]',
  md: 'size-5 border-2',
  lg: 'size-8 border-2',
} as const

export function Spinner({ size = 'md', className, label }: SpinnerProps) {
  return (
    <span
      className={cn('inline-flex items-center justify-center', className)}
      role={label ? 'status' : undefined}
    >
      <span
        className={cn(
          'animate-spin-token rounded-full border-current border-t-transparent opacity-70',
          sizeMap[size],
        )}
        aria-hidden="true"
      />
      {label ? <span className="sr-only">{label}</span> : null}
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For waits short enough that layout should not change. For anything longer, a Skeleton communicates more, because it shows what is arriving.

  • Pass `label` only when the spinner is the sole indication of a wait; inside a Button the button already announces itself busy.
  • Drawn with a border rather than an SVG, so it inherits colour and needs no fill props.
  • Under `prefers-reduced-motion` the rotation stops — which is exactly why the accessible label matters.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Four sizes
  • Labelled — standalone
  • Unlabelled — inside a labelled control

Accessibility

Status role
A labelled spinner is a polite `role="status"`; an unlabelled one is fully decorative.
Reduced motion
The global reset disables the animation; the label continues to convey the state.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.