Web InnoventixFreeCode

Dual Ring Loader

Original · free

dual counter-rotating rings

byWeb InnoventixReact + Tailwind
loaddualringloaders
Open

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.json
load-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 quote

Similar components

Browse all →