Spinner
A component to display a loading state with several visual variants.
import { Spinner } from "@/components/spinner";
export default function SpinnerExample() {
return <Spinner />;
}Source Code
import type { VariantProps } from "cva";
import { useEffect, useState } from "react";
import { cva } from "@/lib/utils/classnames";
type SpinnerSize = "xs" | "sm" | "md" | "lg";
type SpinnerVariant = "ring" | "dots" | "bars" | "frames";
interface BaseSpinnerProps extends React.ComponentPropsWithRef<"div"> {
size?: SpinnerSize;
}
const ringStyle = cva({
base: [
"relative animate-spin",
"before:absolute before:top-0 before:left-0 before:block before:size-full before:rounded-full before:border-current before:opacity-40",
"after:top-0 after:left-0 after:block after:size-full after:rounded-full after:border-transparent after:border-t-current after:border-r-current",
],
variants: {
size: {
xs: "size-2 before:border after:border",
sm: "size-3 before:border after:border",
md: "size-4 before:border-2 after:border-2",
lg: "size-5 before:border-2 after:border-2",
} satisfies Record<SpinnerSize, string>,
},
});
const SpinnerRing = ({ ref, className, size = "md", ...props }: BaseSpinnerProps) => {
return (
<div
ref={ref}
role="progressbar"
aria-label="loading"
className={ringStyle({ size, className })}
{...props}
/>
);
};
const dotsContainerStyle = cva({
base: "inline-flex items-center",
variants: {
size: {
xs: "gap-0.5",
sm: "gap-0.5",
md: "gap-1",
lg: "gap-1",
} satisfies Record<SpinnerSize, string>,
},
});
const dotStyle = cva({
base: "animate-spinner-dot rounded-full bg-current",
variants: {
size: {
xs: "size-px",
sm: "size-0.5",
md: "size-0.75",
lg: "size-1",
} satisfies Record<SpinnerSize, string>,
},
});
const SpinnerDots = ({ ref, className, size = "md", ...props }: BaseSpinnerProps) => {
return (
<div
ref={ref}
role="progressbar"
aria-label="loading"
className={dotsContainerStyle({ size, className })}
{...props}
>
<span className={dotStyle({ size })} />
<span className={dotStyle({ size })} style={{ animationDelay: "160ms" }} />
<span className={dotStyle({ size })} style={{ animationDelay: "320ms" }} />
</div>
);
};
const barsContainerStyle = cva({
base: "inline-flex items-center",
variants: {
size: {
xs: "h-2 gap-0.5",
sm: "h-3 gap-0.5",
md: "h-4 gap-1",
lg: "h-5 gap-1",
} satisfies Record<SpinnerSize, string>,
},
});
const barStyle = cva({
base: "h-full origin-center animate-spinner-bar bg-current",
variants: {
size: {
xs: "w-px",
sm: "w-0.25",
md: "w-0.5",
lg: "w-0.75",
} satisfies Record<SpinnerSize, string>,
},
});
const SpinnerBars = ({ ref, className, size = "md", ...props }: BaseSpinnerProps) => {
return (
<div
ref={ref}
role="progressbar"
aria-label="loading"
className={barsContainerStyle({ size, className })}
{...props}
>
<span className={barStyle({ size })} />
<span className={barStyle({ size })} style={{ animationDelay: "120ms" }} />
<span className={barStyle({ size })} style={{ animationDelay: "240ms" }} />
</div>
);
};
export const SPINNER_FRAMES = {
braille: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
bounce: ["⠁", "⠂", "⠄", "⠂"],
moon: ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
sparkle: ["✶", "✸", "✹", "✺", "✹", "✷"],
dots: ["●", "◉", "◎", "○", "◌", "◦", "∘", "·"],
shades: ["█", "▓", "▒", "░", " ", "░", "▒", "▓"],
pipe: ["|", "/", "-", "\\"],
} as const satisfies Record<string, readonly string[]>;
const framesStyle = cva({
base: "inline-flex items-center justify-center font-mono tabular-nums leading-none",
variants: {
size: {
xs: "size-2.5 text-2xs",
sm: "size-3 text-xs",
md: "size-4 text-base",
lg: "size-5 text-xl",
} satisfies Record<SpinnerSize, string>,
},
});
interface SpinnerFramesProps extends BaseSpinnerProps {
/** Frames to cycle through. Defaults to `SPINNER_FRAMES.braille`. */
frames?: readonly string[];
/** Milliseconds between frames. */
interval?: number;
}
const SpinnerFrames = ({
ref,
className,
size = "md",
frames = SPINNER_FRAMES.braille,
interval = 80,
...props
}: SpinnerFramesProps) => {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % frames.length);
}, interval);
return () => window.clearInterval(id);
}, [frames.length, interval]);
return (
<div
ref={ref}
role="progressbar"
aria-label="loading"
className={framesStyle({ size, className })}
{...props}
>
<span aria-hidden="true">{frames[index]}</span>
</div>
);
};
export interface SpinnerProps extends BaseSpinnerProps, VariantProps<typeof ringStyle> {
variant?: SpinnerVariant;
/** Frames to cycle through when `variant="frames"`. */
frames?: readonly string[];
/** Milliseconds between frames when `variant="frames"`. */
interval?: number;
}
const Spinner = ({ variant = "ring", frames, interval, ...props }: SpinnerProps) => {
if (variant === "dots") return <SpinnerDots {...props} />;
if (variant === "bars") return <SpinnerBars {...props} />;
if (variant === "frames") return <SpinnerFrames frames={frames} interval={interval} {...props} />;
return <SpinnerRing {...props} />;
};
export { Spinner };API Reference
Extends the div element.
| Prop | Default | Type | Description |
|---|---|---|---|
variant | "ring" | "ring""dots""bars""frames" | The visual style of the loader. |
size | "md" | "xs""sm""md""lg" | |
frames | - | readonly string[] | Only for variant="frames". The characters to cycle through. Defaults to SPINNER_FRAMES.braille. |
interval | 80 | number | Only for variant="frames". Milliseconds between frames. |
Variants
ring, dots, and bars are pure CSS. frames cycles through any array of characters via setInterval. All variants animate regardless of prefers-reduced-motion — a loading indicator communicates state, so freezing it would break the signal.
ring
import { Spinner } from "@/components/spinner";
const variants = ["ring", "dots", "bars", "frames"] as const;
export default function SpinnerVariantsPreview() {
return (
<div className="grid grid-cols-2 items-center gap-x-12 gap-y-8 sm:grid-cols-4">
{variants.map((variant) => (
<div key={variant} className="flex flex-col items-center gap-4">
<div className="flex size-6 items-center justify-center">
<Spinner variant={variant} />
</div>
<span className="text-center text-foreground-secondary text-xs">{variant}</span>
</div>
))}
</div>
);
}Frame presets
A handful of preset frame sets are exported as SPINNER_FRAMES. Pass any string array — the project’s own emoji set, ASCII bars, whatever fits.
import { Spinner, SPINNER_FRAMES } from "@/foundations/ui/spinner/spinner";
<Spinner variant="frames" frames={SPINNER_FRAMES.moon} interval={120} />
<Spinner variant="frames" frames={["—", "\\", "|", "/"]} />
import { SPINNER_FRAMES, Spinner } from "@/components/spinner";
const presets: Array<{
name: keyof typeof SPINNER_FRAMES;
interval?: number;
}> = [
{ name: "braille" },
{ name: "bounce", interval: 140 },
{ name: "moon", interval: 120 },
{ name: "sparkle", interval: 140 },
{ name: "dots", interval: 120 },
{ name: "shades", interval: 100 },
{ name: "pipe" },
];
export default function SpinnerFramesPreview() {
return (
<div className="grid grid-cols-2 items-center gap-x-12 gap-y-8 sm:grid-cols-4">
{presets.map(({ name, interval }) => (
<div key={name} className="flex flex-col items-center gap-4">
<div className="flex size-6 items-center justify-center">
<Spinner variant="frames" frames={SPINNER_FRAMES[name]} interval={interval} />
</div>
<span className="text-center text-foreground-secondary text-xs">{name}</span>
</div>
))}
</div>
);
}Examples
Sizes
import { Spinner } from "@/components/spinner";
export default function SpinnerSizesExample() {
return (
<div className="flex flex-col items-center gap-4">
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
</div>
);
}Color
The spinner inherits currentColor, so any text color utility works.
import { Spinner } from "@/components/spinner";
export default function SpinnerColorExample() {
return <Spinner className="text-emerald-500" />;
}Best Practices
-
Usage:
- Use the default
ringfor buttons and tight inline contexts — it’s pure CSS and cheap. - Use
dotsorbarswhen the loader sits on its own (panel header, empty state). - Use
framesfor terminal/CLI-flavored UIs or to match a specific brand voice. - Consider Skeleton instead when you have layout to fill.
- Use the default
-
Accessibility:
- All variants render with
role="progressbar"andaria-label="loading". - Loading indicators animate regardless of
prefers-reduced-motion— they communicate state, not decoration. If the surrounding page has many spinners visible at once, preferSkeletoninstead, which doesn’t animate at all.
- All variants render with
Previous
Slider
Next
Switch