Feedback

Spinner

One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Custom keyframes ship inside the component via a React 19 hoisted <style> —
 * no Tailwind config edits, duplicate spinners dedupe by href. Plain rotation
 * reuses the built-in animate-spin utility.
 *
 * The reduced-motion block keeps a frozen dot row readable: each dot rests at
 * its own opacity (--sp-rest), so a stopped trio still reads as "loading"
 * rather than as three identical bullets.
 */
const KEYFRAMES = `@keyframes sp-rev{to{transform:rotate(-360deg)}}
@keyframes sp-stretch{0%,100%{transform:scaleY(0.45);opacity:0.6}50%{transform:scaleY(1);opacity:1}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/spinner.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Spinner" component (no dependencies
beyond React; pure markup + CSS, no timers and no state).

Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props:
  - variant = "ring" | "dual-ring" | "bars" | "dots" | "ellipsis" | "bounce" |
    "wave" (default "ring")
  - size = "xs" | "sm" | "md" | "lg" (default "md")
  - tone = "current" | "primary" | "muted" (default "current")
  - count = number (default 3, clamped to >= 1) — inline variants only
  - label = string (default "Loading")
  - className merged last via cn(); remaining props spread on the root span.
- Root is role="status" with aria-label={label} plus an sr-only text node, so
  the wait is announced politely (role=status is an implicit live region);
  every visual part is aria-hidden.

Two families, and `size` deliberately means something different in each — this
is the axis the whole component is organised around:
- BOXED (ring / dual-ring / bars): occupies a fixed square, `size` is the
  square's edge (xs size-3 / sm size-4 / md size-6 / lg size-8). Children are
  sized in percentages of that box so one set of classes covers every size.
  Use inside buttons, toolbars, card corners — the footprint never shifts.
- INLINE (dots / ellipsis / bounce / wave): a row of round dots that flows with
  text, `size` is the dot diameter (xs size-1 / sm size-1.5 / md size-2 /
  lg size-2.5) with a matching gap. Total width grows with `count`; the row
  never raises line-height. Use mid-sentence or inside a chat bubble.
Document this split in the props' JSDoc — a consumer must not have to read the
source to learn that `size` switches meaning.

Behavior
- ring: full-inset rounded border with border-t-transparent, rotated by the
  built-in animate-spin utility — the classic gap ring.
- dual-ring: the same outer ring plus an inner ring (inset ~22%,
  border-b-transparent, lower opacity) spinning in reverse via a custom
  keyframe — two counter-rotating arcs.
- bars: three vertical bars stretching on scaleY (0.45 to 1, origin center),
  phases offset with negative animation-delays (-0.4s / -0.2s / 0).
- dots: dots scale-pulsing (0.55 to 1 with opacity).
- ellipsis: dots fading 0.25 to 1 — the quietest row, for text-adjacent use.
- bounce: dots hopping on translateY (-55%).
- wave: dots riding a sine up and down (+-30%), the slowest at 1.4s.
- Inline dots are phased with a positive per-index animation-delay
  (index * 0.16s) so the row reads left to right at any `count`.
- Ship every custom keyframe in ONE React 19 hoisted
  <style href precedence="medium"> tag — no Tailwind config edits, and multiple
  spinners on a page dedupe to a single style tag. Anything expressible with
  the built-in animate-spin utility uses it instead of a custom keyframe.
- Reduced motion: never remove the indicator. Boxed parts get
  motion-reduce:animate-none / motion-reduce:[animation:none], leaving a static
  3/4 ring or solid bars. Inline dots additionally rest on a rising opacity
  ramp: each dot carries a --sp-rest CSS variable (0.35 climbing to 1 across
  the row) applied inside a @media (prefers-reduced-motion: reduce) block, so a
  frozen row still reads as "loading" rather than as identical bullets.
- No "use client": there is no state, no effect and no browser API, so the
  component is safe inside a server component.

Rendering & styling
- Semantic tokens only. tone maps to border-current / border-primary /
  border-muted-foreground for the ring family and bg-current / bg-primary /
  bg-muted-foreground for everything solid. tone="current" inherits whatever
  text token surrounds it (text-primary, text-primary-foreground inside a
  filled button), so one component works on every surface with zero color
  decisions of its own. No hardcoded colors anywhere.
- Boxed root: relative inline-flex shrink-0 items-center justify-center — it
  behaves like an icon. Inline root: inline-flex items-center align-middle
  leading-none — it behaves like a glyph in the text run.

Customization levers
- Recolor: leave the component alone and set a text token on the consumer, or
  pin it with tone="primary" / "muted". Dark mode is free because tokens flip.
- Speed: durations are the only knob — animate-spin is 1s (slow it per instance
  with [animation-duration:1.5s]); dots/ellipsis/bounce 1.2s, bars 1s, wave
  1.4s. Keep the delay step near duration/7 or the row stops reading as a wave.
- Add a shape: one entry in the variant union plus either a conditional JSX
  block (boxed) or one keyframe added to the dot-animation map (inline) — the
  contract stays untouched.
- Density: `count` reshapes the inline row (5-7 dots for a wider, slower
  "thinking" feel); boxed variants ignore it by design.
- Ring thickness: border-2 on ring/dual-ring — raise to border-[3px] for the
  lg size if 2px reads too thin.

Concepts

  • Indeterminate wait — a spinner claims only "work is happening", not how much or in what shape; when progress is measurable reach for Progress Meter, when the pending layout is known reach for Skeleton.
  • Boxed vs inline families — the same "loading" message needs two different geometries: a fixed square that can sit in a button without moving anything, and a dot row that flows inside a sentence without raising line-height. One component covers both, and size is documented to mean the box edge in the first and the dot diameter in the second.
  • Phase-offset dots — each dot's animation-delay is derived from its index, so three or seven dots sharing one keyframe form a left-to-right wave, and none of them sits frozen waiting for its first cycle.
  • Tone over color — every stroke and fill resolves to current / primary / muted-foreground, so recoloring is either a text token on an ancestor or one prop; the component owns zero color decisions and dark mode comes free.
  • Status live regionrole="status" with an sr-only label announces the wait politely without stealing focus; the moving parts are aria-hidden so assistive tech hears one message, not seven decorations.
  • Reduced-motion resting ramp — under prefers-reduced-motion the animation is removed but the glyph is not: boxed shapes stay static, and the inline row rests on a rising opacity ramp (--sp-rest) so a stopped row still reads as "loading" instead of as three identical bullets.
  • Hoisted keyframes — React 19 dedupes <style href precedence> tags into the head, letting a registry component carry its own @keyframes with zero Tailwind config edits.

On This Page