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.
Install
bunx shadcn@latest add @vernostudio/spinnerOr, 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.jsonSizes
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.
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 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.
<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.
<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.
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>
);
};