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