← All components

Spinner

Eight ticks whose brightness walks round the circle while nothing rotates.

Indexing 4 sources

The idea

An arc spinning at 14px smears into a grey ring; eight ticks holding still while their brightness walks round stay legible at any size and cost only opacity. It takes the colour of the text around it, so it is light on an ink button and dark on the page. Under reduced motion the walk stops and the ticks hold as a still gradient, which still reads as working.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Spinner — usage4 lines
import { Spinner } from "mihirmodi-ui";

// Takes the colour of the text around it.
<Spinner size={16} label="Loading" />

Props

PropNotes
sizenumberDiameter in px.
labelstringMakes it a status with this name. Omit it inside something already marked busy.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/actions/Spinner.tsx47 lines
"use client";

export type SpinnerProps = {
  /** Diameter in px. */
  size?: number;
  /** The accessible name. Omit it when the spinner sits inside something that already says it is busy. */
  label?: string;
  className?: string;
};

const TICKS = 8;

/**
 * Eight ticks whose brightness walks round the circle, rather than an arc that
 * spins. A spinning arc is one shape moving fast, and at 14px it smears into a
 * grey ring; ticks that fade in turn are eight shapes holding still, so the
 * motion stays legible at any size and costs only opacity.
 *
 * It takes the colour of the text around it, so on an ink button it is
 * light and on the page it is dark — nothing to configure. Under reduced motion
 * the walk stops and the ticks hold at a gradient, which still reads as
 * "working" without anything moving.
 */
export function Spinner({ size = 16, label, className = "" }: SpinnerProps) {
  return (
    <span
      role={label ? "status" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={`ui-spinner relative inline-block shrink-0 ${className}`}
      style={{ width: size, height: size }}
    >
      {Array.from({ length: TICKS }, (_, i) => (
        <span
          key={i}
          className="absolute inset-0"
          style={{ transform: `rotate(${(360 / TICKS) * i}deg)` }}
        >
          <span
            className="ui-spinner-tick absolute left-1/2 top-0 h-[28%] w-[11%] min-w-[1.5px] -translate-x-1/2 rounded-full bg-current"
            style={{ animationDelay: `${(-1 + i / TICKS).toFixed(3)}s`, opacity: 0.25 + (0.75 * i) / TICKS }}
          />
        </span>
      ))}
    </span>
  );
}