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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/spinner.jsonPrompt
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
sizeis documented to mean the box edge in the first and the dot diameter in the second. - Phase-offset dots — each dot's
animation-delayis 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 region —
role="status"with an sr-only label announces the wait politely without stealing focus; the moving parts arearia-hiddenso assistive tech hears one message, not seven decorations. - Reduced-motion resting ramp — under
prefers-reduced-motionthe 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@keyframeswith zero Tailwind config edits.
Feature Flag Rollout
The rollout control for one flag — a hashed percentage gate with its estimated reach, ordered first-match-wins targeting rules, and a live evaluation preview that names the deciding rule or bucket.
Progress Meter
One progress contract, two geometries — a linear bar or a compact ring, both with determinate and indeterminate modes, four tones and four sizes.