Spinner

A wireframe atom that assembles itself and then turns in space: three orbits arrive one per beat, then the whole thing turns as one. Three sizes, in the current text color.

A wireframe atom that assembles itself and then turns in space. The three orbits arrive one per beat on the way in, and after that the whole thing turns as one solid object, which is the only thing a spinner can honestly say: this is working, and nobody knows for how long.

Loading
The mark at 96px. It builds once on mount, then turns.

Install

bunx shadcn@latest add @vernostudio/spinner

Or, if components.json has no registry namespace to resolve @vernostudio against, point the CLI straight at the file:

bunx shadcn@latest add https://verno-studio.vercel.app/r/spinner.json

Sizes

Three steps, each double the last: 32, 48 and 96. The mark is geometry rather than a glyph, so it scales by doubling instead of by the 4px grid, and the stroke steps with it: 1px at sm, 1.25 at md, 1.5 at lg, which is what the drawing's own 1.1875 user units come to at 96.

Those are px on purpose. Every shape draws with non-scaling-stroke, so the width is read in the viewport rather than in the 76-unit grid and the size step cannot thin it. Size and stroke are one variant rather than two props, so they cannot be set apart: stroke-width is inherited, so cva puts both on the box and every circle below it picks the stroke up.

sm, md and lg. Below 32 the orbits start crossing inside a single pixel.

lg is the page waiting on its first byte. md is a panel or a card. sm sits beside a line of text, and is the floor: three ellipses and a core cannot be drawn smaller without collapsing into a smudge. This is a mark, not a glyph, so it does not shrink into a 40px button. Where you need a 16px spinner, you need a different drawing.

Entrance

The atom is not there and then complete: it arrives a beat at a time. A bare sphere, then one orbit, then two, then three, 100ms apart, each fading in over 300ms. It plays once, on mount, and never again, which is what keeps it an entrance rather than a second thing to watch.

The four stages of the entrance, held still and put side by side.

The cost of building rather than appearing is 600ms before the mark is whole, which is short enough that a load resolving under it was never going to need a spinner. Beyond that the beats stay a beat apart rather than stretching, so the entrance never competes with the wait it introduces.

Color

There are no spinner tokens. Every stroke is currentColor and the orbits carry it at 35% opacity, so the mark takes the color of the text around it and follows it into whatever theme, surface or hover state that text is in. The default is text-gray-700, the faintest step that still holds its shape on both surfaces; className overrides it like any other color.

The same component on three surfaces. Only the text color changes.
<Spinner className="text-background-100" size="sm" />

Labels

label defaults to "Loading" and renders as visually hidden text inside a role="status", which is what a screen reader announces when the spinner is the only thing on the surface. Pass label={null} when visible text already says it, and the whole element goes decorative rather than reading the word twice.

Deploying to productionReading the build log
Both spinners pass label={null}. The words next to them are the label.
<Spinner size="lg" />
<Spinner label="Deploying" size="lg" />
<Spinner label={null} size="sm" />

A live region announces changes to itself, not its arrival, so a spinner that mounts into an empty page may say nothing at all. Where content swaps in place, put aria-busy on the region that is swapping and let the spinner be decorative.

Motion

The orbits are not ellipses. Each one is a full circle tilted 63.187 degrees out of the screen, an angle picked because its cosine is 16.6 over 36.8, the drawing's own ry over its rx: standing still they project to exactly the mark this started from. They sit in a stack that keeps its depth, and that stack turns, once every 8s, so all three turn together the way the parts of a solid thing do. Rings pass edge-on, swing back out, and the figure never comes apart, because there is nothing to come apart: it is one rotation of one object.

That is also why it is smooth. A single transform on a single element is work the compositor can do on its own; squashing three ellipses instead would put the browser back to recomputing geometry, and a stroke that refuses to scale with it, sixty times a second for every spinner on the page.

The sphere and the core stay outside the turn. They are rotationally symmetric, so rotating them would cost frames and show nothing.

The entrance is the second animation, and the last: one set of keyframes for all three orbits, because they differ by delay and nothing else. Everything ships with the component the way the badge's tokens ship with the badge, so shadcn add writes the keyframes into your stylesheet, pulls the theme in behind them for the neutral the mark is drawn in, and there is nothing to wire up.

Under prefers-reduced-motion the turn is dropped, the orbits keep the tilts they were drawn at, and they fade instead: a 2.4s pulse on three phases, between full strength and almost gone. The preference asks for transforms to be replaced by opacity, not for the indicator to stop indicating, and a mark standing perfectly still says the work has finished when it has not.

Source

One file, one dependency, no client boundary, and three sets of keyframes that come with it, one of which is a single line. cva holds the size steps, your cn merges them, and spinnerVariants is exported on its own for the rare surface that needs the look without the element. The geometry is Vercel's, kept at the numbers it was drawn with; the size scale, the strokes, the labelling and the beats are this registry's.

components/spinner.tsx
import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils";

/** Size and stroke travel together. The stroke is set in px and every shape
 *  draws it with `non-scaling-stroke`, so it is a true hairline at any size;
 *  `lg` matches the 1.5px the drawing was authored at. `stroke-width` is
 *  inherited, so setting it on the box reaches every circle below it. */
export const spinnerVariants = cva("relative", {
  defaultVariants: { size: "md" },
  variants: {
    size: {
      lg: "size-24 [stroke-width:1.5px]",
      md: "size-12 [stroke-width:1.25px]",
      sm: "size-8 [stroke-width:1px]",
    },
  },
});

/** The orbits are rings in space, not ellipses: each is drawn as a full circle
 *  and tilted out of the screen by 63.187 degrees, whose cosine is 16.6 / 36.8,
 *  the drawing's own ry over its rx. Standing still they project to exactly the
 *  mark this started from; turning, they behave like one rigid object.
 *
 *  Each fades in a beat after the one before it, once. Under reduced motion the
 *  turn goes and a slow fade takes its place on three phases: a spinner still
 *  has to say that something is happening, and opacity is the one channel the
 *  preference leaves open. The classes are written out rather than built from
 *  the numbers because Tailwind reads this file as text. */
const ORBITS = [
  {
    angle: 30,
    entrance:
      "animate-[spinner-orbit-in_0.3s_ease-out_0.1s_both] motion-reduce:animate-[spinner-orbit-pulse_2.4s_ease-in-out_infinite]",
    tilt: "[transform:rotate(30deg)_rotateX(63.187deg)]",
  },
  {
    angle: 90,
    entrance:
      "animate-[spinner-orbit-in_0.3s_ease-out_0.2s_both] motion-reduce:animate-[spinner-orbit-pulse_2.4s_ease-in-out_-0.8s_infinite]",
    tilt: "[transform:rotate(90deg)_rotateX(63.187deg)]",
  },
  {
    angle: -30,
    entrance:
      "animate-[spinner-orbit-in_0.3s_ease-out_0.3s_both] motion-reduce:animate-[spinner-orbit-pulse_2.4s_ease-in-out_-1.6s_infinite]",
    tilt: "[transform:rotate(-30deg)_rotateX(63.187deg)]",
  },
] as const;

/** One rotation, on one element, of a stack that keeps its depth: the orbits
 *  hold their own tilts inside it, so the whole assembly turns the way a solid
 *  thing turns. It is also the only property animated while the spinner is up,
 *  which is what keeps the turn on the compositor and off the main thread. */
const ATOM_TURN =
  "absolute inset-0 [transform-style:preserve-3d] animate-[spinner-atom-turn_8s_linear_infinite] motion-reduce:animate-none";

interface SpinnerProps {
  readonly className?: string;
  /** Announced by assistive tech. Pass `null` where visible text already says
   *  the same thing, which leaves the spinner decorative. */
  readonly label?: string | null;
  readonly size?: "sm" | "md" | "lg";
}

export const Spinner = ({ className, label = "Loading", size = "md" }: SpinnerProps) => {
  const decorative = label === null;

  return (
    <span
      aria-hidden={decorative || undefined}
      className={cn("inline-flex shrink-0 items-center justify-center text-gray-700", className)}
      role={decorative ? undefined : "status"}
    >
      <span aria-hidden="true" className={spinnerVariants({ size })}>
        <svg className="absolute inset-0 size-full" viewBox="0 0 76 76">
          <circle
            cx="38"
            cy="38"
            fill="none"
            r="36.85"
            stroke="currentColor"
            vectorEffect="non-scaling-stroke"
          />
          <circle
            cx="38"
            cy="38"
            fill="none"
            r="2.3"
            stroke="currentColor"
            vectorEffect="non-scaling-stroke"
          />
        </svg>
        <span className={ATOM_TURN}>
          {ORBITS.map((orbit) => (
            <span
              className={cn("absolute inset-0 opacity-35", orbit.tilt, orbit.entrance)}
              key={orbit.angle}
            >
              <svg className="size-full" viewBox="0 0 76 76">
                <circle
                  cx="38"
                  cy="38"
                  fill="none"
                  r="36.8"
                  stroke="currentColor"
                  vectorEffect="non-scaling-stroke"
                />
              </svg>
            </span>
          ))}
        </span>
      </span>
      {decorative ? null : <span className="sr-only">{label}</span>}
    </span>
  );
};