Dual Ring Loader
Original · freedual counter-rotating rings
byWeb InnoventixReact + Tailwind
loaddualringloaders
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/load-dual-ring.jsonload-dual-ring.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import { useReducedMotion } from "motion/react";
type Tempo = "slow" | "normal" | "fast";
type Accent = "indigo" | "violet" | "emerald" | "sky" | "rose" | "amber";
type Phase = "idle" | "loading" | "done";
const TEMPO: Record<Tempo, { label: string; outer: number; inner: number }> = {
slow: { label: "Slow", outer: 3.2, inner: 2.4 },
normal: { label: "Normal", outer: 1.8, inner: 1.35 },
fast: { label: "Fast", outer: 1.0, inner: 0.72 },
};
const ACCENTS: Record<
Accent,
{
name: string;
outer: string;
inner: string;
dot: string;
swatch: string;
ring: string;
slider: string;
solid: string;
blob: string;
}
> = {
indigo: {
name: "Indigo",
outer: "text-indigo-500 dark:text-indigo-400",
inner: "text-indigo-300 dark:text-indigo-600",
dot: "bg-indigo-500 dark:bg-indigo-400",
swatch: "bg-indigo-500",
ring: "peer-checked:ring-indigo-500 peer-focus-visible:ring-indigo-500",
slider: "accent-indigo-500 dark:accent-indigo-400",
solid: "bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-500",
blob: "bg-indigo-400",
},
violet: {
name: "Violet",
outer: "text-violet-500 dark:text-violet-400",
inner: "text-violet-300 dark:text-violet-600",
dot: "bg-violet-500 dark:bg-violet-400",
swatch: "bg-violet-500",
ring: "peer-checked:ring-violet-500 peer-focus-visible:ring-violet-500",
slider: "accent-violet-500 dark:accent-violet-400",
solid: "bg-violet-600 hover:bg-violet-500 focus-visible:ring-violet-500",
blob: "bg-violet-400",
},
emerald: {
name: "Emerald",
outer: "text-emerald-500 dark:text-emerald-400",
inner: "text-emerald-300 dark:text-emerald-600",
dot: "bg-emerald-500 dark:bg-emerald-400",
swatch: "bg-emerald-500",
ring: "peer-checked:ring-emerald-500 peer-focus-visible:ring-emerald-500",
slider: "accent-emerald-500 dark:accent-emerald-400",
solid: "bg-emerald-600 hover:bg-emerald-500 focus-visible:ring-emerald-500",
blob: "bg-emerald-400",
},
sky: {
name: "Sky",
outer: "text-sky-500 dark:text-sky-400",
inner: "text-sky-300 dark:text-sky-600",
dot: "bg-sky-500 dark:bg-sky-400",
swatch: "bg-sky-500",
ring: "peer-checked:ring-sky-500 peer-focus-visible:ring-sky-500",
slider: "accent-sky-500 dark:accent-sky-400",
solid: "bg-sky-600 hover:bg-sky-500 focus-visible:ring-sky-500",
blob: "bg-sky-400",
},
rose: {
name: "Rose",
outer: "text-rose-500 dark:text-rose-400",
inner: "text-rose-300 dark:text-rose-600",
dot: "bg-rose-500 dark:bg-rose-400",
swatch: "bg-rose-500",
ring: "peer-checked:ring-rose-500 peer-focus-visible:ring-rose-500",
slider: "accent-rose-500 dark:accent-rose-400",
solid: "bg-rose-600 hover:bg-rose-500 focus-visible:ring-rose-500",
blob: "bg-rose-400",
},
amber: {
name: "Amber",
outer: "text-amber-500 dark:text-amber-400",
inner: "text-amber-300 dark:text-amber-600",
dot: "bg-amber-500 dark:bg-amber-400",
swatch: "bg-amber-500",
ring: "peer-checked:ring-amber-500 peer-focus-visible:ring-amber-500",
slider: "accent-amber-500 dark:accent-amber-400",
solid: "bg-amber-600 hover:bg-amber-500 focus-visible:ring-amber-500",
blob: "bg-amber-400",
},
};
const R_OUTER = 42;
const R_INNER = 28;
const C_OUTER = 2 * Math.PI * R_OUTER;
const C_INNER = 2 * Math.PI * R_INNER;
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4 fill-current">
<rect x="6" y="5" width="4" height="14" rx="1.2" />
<rect x="14" y="5" width="4" height="14" rx="1.2" />
</svg>
);
}
function CheckIcon({ size }: { size: number }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="text-emerald-500 dark:text-emerald-400"
style={{ width: size * 0.34, height: size * 0.34 }}
>
<path
d="M20 6 9 17l-5-5"
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
type DualRingProps = {
size: number;
accent: Accent;
tempo: Tempo;
running: boolean;
phase: Phase;
progress: number;
reduced: boolean;
};
function DualRing({ size, accent, tempo, running, phase, progress, reduced }: DualRingProps) {
const a = ACCENTS[accent];
const t = TEMPO[tempo];
const determinate = phase === "loading" || phase === "done";
const done = phase === "done";
const outerSpinClass = !reduced && !determinate ? "drl-cw" : "";
const innerSpinClass = !reduced && phase !== "done" ? "drl-ccw" : "";
const playState = running ? "running" : "paused";
const dotPx = Math.max(8, Math.round(size * 0.085));
return (
<div className="relative grid place-items-center" style={{ width: size, height: size }}>
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90" role="presentation">
{/* faint tracks */}
<circle
cx="50"
cy="50"
r={R_OUTER}
fill="none"
strokeWidth={6}
stroke="currentColor"
className="text-slate-200 dark:text-slate-800"
/>
<circle
cx="50"
cy="50"
r={R_INNER}
fill="none"
strokeWidth={5}
stroke="currentColor"
className="text-slate-200 dark:text-slate-800"
/>
{/* outer ring — clockwise, becomes a progress arc while loading */}
<g
className={outerSpinClass}
style={{ animationDuration: `${t.outer}s`, animationPlayState: playState }}
>
<circle
cx="50"
cy="50"
r={R_OUTER}
fill="none"
strokeWidth={6}
strokeLinecap="round"
stroke="currentColor"
className={`${
done ? "text-emerald-500 dark:text-emerald-400" : a.outer
} transition-[stroke-dashoffset] duration-150 ease-linear`}
strokeDasharray={
determinate ? `${C_OUTER}` : `${C_OUTER * 0.72} ${C_OUTER * 0.28}`
}
strokeDashoffset={determinate ? C_OUTER * (1 - progress / 100) : 0}
/>
</g>
{/* inner ring — counter-clockwise */}
<g
className={innerSpinClass}
style={{ animationDuration: `${t.inner}s`, animationPlayState: playState }}
>
<circle
cx="50"
cy="50"
r={R_INNER}
fill="none"
strokeWidth={5}
strokeLinecap="round"
stroke="currentColor"
className={a.inner}
strokeDasharray={`${C_INNER * 0.55} ${C_INNER * 0.45}`}
/>
</g>
</svg>
{/* centre readout (decorative — screen readers use the status region below) */}
<div className="absolute inset-0 grid place-items-center" aria-hidden="true">
{done ? (
<CheckIcon size={size} />
) : determinate ? (
<span
className="font-semibold tabular-nums text-slate-700 dark:text-slate-100"
style={{ fontSize: Math.max(12, Math.round(size * 0.16)) }}
>
{Math.round(progress)}%
</span>
) : (
<span
className={`block rounded-full ${a.dot} ${running && !reduced ? "drl-dot" : ""}`}
style={{ width: dotPx, height: dotPx }}
/>
)}
</div>
</div>
);
}
export default function LoadDualRing() {
const prefersReduced = useReducedMotion();
const reduced = prefersReduced === true;
const uid = useId();
const [running, setRunning] = useState(true);
const [tempo, setTempo] = useState<Tempo>("normal");
const [accent, setAccent] = useState<Accent>("indigo");
const [size, setSize] = useState(128);
const [phase, setPhase] = useState<Phase>("idle");
const [progress, setProgress] = useState(0);
const progressRef = useRef(0);
useEffect(() => {
if (phase !== "loading") return;
setProgress(0);
progressRef.current = 0;
const id = window.setInterval(() => {
const next = Math.min(100, progressRef.current + Math.random() * 6 + 2.5);
progressRef.current = next;
setProgress(next);
if (next >= 100) {
window.clearInterval(id);
setPhase("done");
}
}, 150);
return () => window.clearInterval(id);
}, [phase]);
function handlePrimary() {
if (phase === "idle") {
setPhase("loading");
} else {
setProgress(0);
progressRef.current = 0;
setPhase("idle");
}
}
const a = ACCENTS[accent];
const primaryLabel =
phase === "idle" ? "Simulate a load" : phase === "loading" ? "Cancel" : "Reset";
const busy = phase === "loading" || (phase === "idle" && running);
const statusLabel =
phase === "done"
? "Complete — your results are ready"
: phase === "loading"
? "Loading — fetching results"
: running
? "Active — task running in the background"
: "Paused — spinner idle";
const statusDot =
phase === "done"
? "bg-emerald-500 dark:bg-emerald-400"
: phase === "loading"
? a.dot
: running
? a.dot
: "bg-slate-400 dark:bg-slate-500";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-white to-slate-50 px-6 py-20 sm:py-28 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes drl-rotate-cw { to { transform: rotate(360deg); } }
@keyframes drl-rotate-ccw { to { transform: rotate(-360deg); } }
@keyframes drl-breathe {
0%, 100% { transform: scale(0.68); opacity: 0.45; }
50% { transform: scale(1); opacity: 1; }
}
.drl-cw, .drl-ccw {
transform-box: fill-box;
transform-origin: 50% 50%;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.drl-cw { animation-name: drl-rotate-cw; }
.drl-ccw { animation-name: drl-rotate-ccw; }
.drl-dot { animation: drl-breathe 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.drl-cw, .drl-ccw, .drl-dot { animation: none !important; }
}
`}</style>
{/* soft accent glow, purely decorative */}
<div
aria-hidden="true"
className={`pointer-events-none absolute left-1/2 top-0 h-72 w-72 -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-25 ${a.blob}`}
/>
<div className="relative mx-auto max-w-4xl">
<header className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-widest text-slate-600 shadow-sm dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300">
<span className={`h-1.5 w-1.5 rounded-full ${a.dot}`} />
Loaders
</span>
<h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Dual counter-rotating rings
</h2>
<p className="mt-3 text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Two arcs sweep in opposite directions so the loader always reads as deliberate,
continuous motion — never a frozen frame. Tune the tempo, scale, and accent, or run a
determinate simulation to watch it resolve to a finished state.
</p>
</header>
<div className="mt-12 grid gap-6 rounded-3xl border border-slate-200 bg-white/70 p-6 shadow-sm backdrop-blur-sm sm:p-8 md:grid-cols-2 md:items-center dark:border-slate-800 dark:bg-slate-900/60">
{/* preview stage */}
<div className="flex flex-col items-center gap-6 rounded-2xl border border-slate-200/70 bg-slate-50 p-8 dark:border-slate-800 dark:bg-slate-950/50">
<DualRing
size={size}
accent={accent}
tempo={tempo}
running={running}
phase={phase}
progress={progress}
reduced={reduced}
/>
<div
role="status"
aria-live="polite"
aria-atomic="true"
aria-busy={busy}
className="flex items-center gap-2.5 rounded-full bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-200 dark:ring-slate-800"
>
<span className={`h-2 w-2 rounded-full ${statusDot}`} aria-hidden="true" />
{statusLabel}
</div>
</div>
{/* controls */}
<div className="flex flex-col gap-6">
{/* tempo */}
<fieldset className="min-w-0">
<legend className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Tempo
</legend>
<div role="radiogroup" aria-label="Animation tempo" className="grid grid-cols-3 gap-2">
{(Object.keys(TEMPO) as Tempo[]).map((key) => (
<label key={key} className="cursor-pointer">
<input
type="radio"
name={`${uid}-tempo`}
value={key}
checked={tempo === key}
onChange={() => setTempo(key)}
className="peer sr-only"
/>
<span className="block rounded-xl border border-slate-200 bg-white px-3 py-2 text-center text-sm font-medium text-slate-700 transition hover:border-slate-300 peer-checked:border-transparent peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-slate-400 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:peer-checked:bg-white dark:peer-checked:text-slate-900 dark:peer-focus-visible:ring-offset-slate-900">
{TEMPO[key].label}
</span>
</label>
))}
</div>
</fieldset>
{/* accent */}
<fieldset className="min-w-0">
<legend className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Accent
</legend>
<div role="radiogroup" aria-label="Accent colour" className="flex flex-wrap gap-2.5">
{(Object.keys(ACCENTS) as Accent[]).map((key) => (
<label key={key} className="cursor-pointer">
<input
type="radio"
name={`${uid}-accent`}
value={key}
checked={accent === key}
onChange={() => setAccent(key)}
aria-label={ACCENTS[key].name}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={`block h-8 w-8 rounded-full ring-2 ring-transparent ring-offset-2 ring-offset-slate-50 transition hover:scale-105 ${ACCENTS[key].swatch} ${ACCENTS[key].ring} dark:ring-offset-slate-900`}
/>
</label>
))}
</div>
</fieldset>
{/* diameter */}
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor={`${uid}-size`}
className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
>
Diameter
</label>
<span className="text-sm tabular-nums text-slate-600 dark:text-slate-300">
{size}px
</span>
</div>
<input
id={`${uid}-size`}
type="range"
min={64}
max={168}
step={4}
value={size}
aria-valuetext={`${size} pixels`}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSize(Number(e.target.value))}
className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-700 dark:focus-visible:ring-offset-slate-900 ${a.slider}`}
/>
</div>
{/* actions */}
<div className="flex flex-wrap gap-3 pt-1">
<button
type="button"
onClick={() => setRunning((v) => !v)}
aria-pressed={running}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
{running ? <PauseIcon /> : <PlayIcon />}
{running ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={handlePrimary}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${a.solid}`}
>
{phase === "loading" ? (
<span
className="h-4 w-4 rounded-full border-2 border-white/40 border-t-white drl-cw"
style={{ animationDuration: "0.7s" }}
aria-hidden="true"
/>
) : null}
{primaryLabel}
</button>
</div>
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Fully keyboard-operable: arrow keys move within the tempo and accent groups, the
slider responds to arrows, and the status pill announces state changes via an
<code className="mx-1 rounded bg-slate-100 px-1 py-0.5 font-mono text-[0.7rem] text-slate-700 dark:bg-slate-800 dark:text-slate-300">
aria-live
</code>
region. Motion is disabled automatically when the OS requests reduced motion.
</p>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Spinner Set Loader
Originalset of circular spinners in sizes

Dots Loader
Originalbouncing dots loader

Bars Loader
Originalequalizer bars loader

Ring Loader
Originalring spinner loader

Pulse Loader
Originalpulsing circle loader

Orbit Loader
Originalorbiting dots loader

Wave Loader
Originalwave loader

Bounce Loader
Originalbouncing ball loader

Gradient Ring Loader
Originalgradient conic ring spinner

Square Loader
Originalmorphing square loader

Typing Loader
Originaltyping indicator loader

Page Overlay Loader
Originalfull-page loading overlay with progress

