Spinner
pnpm dlx shadcn@latest add …/r/spinner.jsonAn accessible React spinner for Next.js and Tailwind CSS: one line going round an infinity that fills it once the task is done.
- Small
- Medium
- Large
- In a button
import { Spinner } from "@/components/ui/spinner"
export function Loading() {
return <Spinner />
}import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
/* The three sizes and one in a button, named underneath */
export default function SpinnerSizes() {
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<Spinner size="sm" />
<span className="text-xs text-muted-foreground">Small</span>
</li>
<li className="flex flex-col items-center gap-2">
<Spinner />
<span className="text-xs text-muted-foreground">Medium</span>
</li>
<li className="flex flex-col items-center gap-2">
<Spinner size="lg" />
<span className="text-xs text-muted-foreground">Large</span>
</li>
<li className="flex flex-col items-center gap-2">
{/* The label already says what's happening, so the spinner is hidden from screen readers */}
<Button variant="outline" disabled>
<Spinner aria-hidden />
Saving…
</Button>
<span className="text-xs text-muted-foreground">In a button</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { type ComponentProps, useEffect } from "react"; /* * A small loading indicator (owner's roadmap, 2026-10-16), redesigned as the infinity the owner chose in the Lab * (2026-10-22), ported from it exactly: the same path, line, timings and finish. In the text's colour. * - One line travels round and round an infinity at an even pace over a faint track. * - `done` shows the task finished: the line stops where it is and grows forward until it fills the whole * infinity, the track fading out under it. Back to loading, it shrinks to a line and travels on. * - Motion: the line is a dash along the path (its offset and length), so only the stroke moves. With reduced * motion it doesn't travel: it pulses its opacity instead, and a finish fills at once. * - role="status", named "Loading" unless you give it another aria-label, and "Done" once finished (doneLabel). * Inside a button that already says what's happening ("Saving…"), hide it with aria-hidden, or the button's * name would include it. * - Sizes are the icon sizes (14, 16 and 20px), so it can stand in for an icon anywhere. */ /** * The infinity as one continuous stroke (a dash restarts at every subpath, so it couldn't run from one loop into * the other): from the centre up into the right loop, round it, back through the centre into the left loop, * round it and home */ const INFINITY = "M12 12C14 9 16 8 18 8A4 4 0 1 1 18 16C16 16 14 15 12 12C10 9 8 8 6 8A4 4 0 1 0 6 16C8 16 10 15 12 12Z"; /** The line's length, in hundredths of the infinity */ const LINE = 24; /** One second: the old spinner's turn; the line goes round in 1.6 of them */ const TURN = duration.slower * 2; const forever = { repeat: Number.POSITIVE_INFINITY } as const; const spinnerVariants = cva("inline-flex shrink-0", { variants: { size: { sm: "size-(--size-icon-sm)", md: "size-(--size-icon-md)", lg: "size-(--size-icon-lg)", }, }, defaultVariants: { size: "md" }, }); export type SpinnerProps = ComponentProps<"span"> & VariantProps<typeof spinnerVariants> & { /** The task is finished: the line fills the whole infinity */ done?: boolean; /** Its name once done, for screen readers */ doneLabel?: string; }; function Spinner({ className, size, done = false, doneLabel = "Done", "aria-label": label = "Loading", ...props }: SpinnerProps) { const reduce = useReducedMotion() ?? false; // Where the line starts along the infinity, and how long it is, in hundredths const offset = useMotionValue(0); const length = useMotionValue(done ? 100 : LINE); const dasharray = useTransform(length, (line) => `${line} ${Math.max(0, 100 - line)}`); useEffect(() => { if (done) { // It stops where it is and fills forward from there const fill = animate( length, 100, reduce ? { duration: 0 } : { duration: duration.slower * 1.6, ease: ease.out }, ); return () => fill.stop(); } const shrink = animate( length, LINE, reduce ? { duration: 0 } : { duration: duration.slow, ease: ease.out }, ); if (reduce) return () => shrink.stop(); // Round and round at an even pace, on from wherever it is const from = offset.get(); const travel = animate(offset, [from, from - 100], { duration: TURN * 1.6, ease: "linear", ...forever }); return () => { shrink.stop(); travel.stop(); }; }, [done, reduce, offset, length]); return ( <span role="status" aria-label={done ? doneLabel : label} data-slot="spinner" data-size={size ?? "md"} data-done={done || undefined} className={cn(spinnerVariants({ size }), className)} {...props} > {/* With reduced motion the drawing pulses while loading; finished, it holds still */} <motion.span key={reduce && !done ? "pulse" : "still"} className="inline-flex size-full" animate={reduce && !done ? { opacity: [1, 0.4, 1] } : { opacity: 1 }} transition={ reduce && !done ? { duration: TURN * 1.5, ease: ease.inOut, ...forever } : { duration: 0 } } > <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-full"> <motion.path d={INFINITY} stroke="currentColor" strokeWidth="2.2" strokeLinejoin="round" initial={false} animate={{ opacity: done ? 0 : 0.15 }} transition={reduce ? { duration: 0 } : { duration: duration.slower, ease: ease.out }} /> <motion.path data-slot="spinner-line" d={INFINITY} pathLength={100} stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ strokeDasharray: dasharray, strokeDashoffset: offset }} /> </svg> </motion.span> </span> ); } export { Spinner, spinnerVariants };Update the import paths to match your project.
Examples
Finish
Set done when the task is finished: the line stops where it is and grows until it fills the whole infinity. Back to loading, it shrinks and travels on.
"use client";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useState } from "react";
/* The finish: done fills the whole infinity. The row is the status, so it's read as its text. */
export default function SpinnerFinish() {
const [done, setDone] = useState(false);
return (
<div className="flex flex-col items-center gap-4">
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner aria-hidden done={done} />
{done ? "Saved" : "Saving…"}
</div>
<Button variant="outline" size="sm" onClick={() => setDone((current) => !current)}>
{done ? "Restart" : "Finish"}
</Button>
</div>
);
}With text
Beside text, put role="status" on the row and hide the spinner, so it's read once as the text.
import { Spinner } from "@/components/ui/spinner";
/* Beside text, the row is the status, so it's read once as "Loading comments…" */
export default function SpinnerWithText() {
return (
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner aria-hidden />
Loading comments…
</div>
);
}API reference
A status (role="status") drawn in currentColor, so it takes the text's colour. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|---|---|---|
size? | "sm" | "md" | "lg" | "md" | 14, 16 or 20px, the icon sizes, so it can stand in for an icon. |
aria-label? | string | "Loading" | What's loading, for screen readers. Inside a button or beside text that already says it, pass aria-hidden instead. |
done? | boolean | false | The task is finished: the line fills the whole infinity and stops. |
doneLabel? | string | "Done" | Its name for screen readers once done. |
Related components
- Loading
An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
- Stablev2.4.0Beta
An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.