shadimage
vernostudio
uispinnersource: ok · refreshed

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.

npx shadcn add "https://verno-studio.vercel.app/r/spinner.json"
free

Visual style

3d

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
class-variance-authority

Registry dependencies

theme

Files1 · source cached 10/8/2026, 8:49:05 AM

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>
  );
};

More from vernostudio

Browse all components in this library →