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.

PropDefaultTypeDescription
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.
interval80numberOnly 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