Agents (llms.txt)
Octocat

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
dots
bars
frames
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={["—", "\\", "|", "/"]} />
        
braille
bounce
moon
sparkle
dots
shades
pipe
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

  1. Usage:

    • Use the default ring for buttons and tight inline contexts — it’s pure CSS and cheap.
    • Use dots or bars when the loader sits on its own (panel header, empty state).
    • Use frames for terminal/CLI-flavored UIs or to match a specific brand voice.
    • Consider Skeleton instead when you have layout to fill.
  2. Accessibility:

    • All variants render with role="progressbar" and aria-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, prefer Skeleton instead, which doesn’t animate at all.

Previous

Slider

Next

Switch