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