Web InnoventixFreeCode

Orbit Loader

Original · free

orbiting dots loader

byWeb InnoventixReact + Tailwind
loadorbitloaders
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-orbit.json
load-orbit.tsx
"use client";

import { useEffect, useId, useState } from "react";
import { useReducedMotion } from "motion/react";

type Density = "minimal" | "balanced" | "dense";

const DOTS_PER_RING: Record<Density, number> = {
  minimal: 1,
  balanced: 2,
  dense: 3,
};

const RINGS = [
  {
    r: 128,
    dot: 13,
    base: 6.0,
    offset: 0,
    track: "border-indigo-200/80 dark:border-indigo-400/15",
    dotCls:
      "bg-indigo-500 dark:bg-indigo-400 shadow-lg shadow-indigo-500/40 dark:shadow-indigo-400/30",
  },
  {
    r: 92,
    dot: 11,
    base: 4.2,
    offset: 40,
    track: "border-violet-200/80 dark:border-violet-400/15",
    dotCls:
      "bg-violet-500 dark:bg-violet-400 shadow-lg shadow-violet-500/40 dark:shadow-violet-400/30",
  },
  {
    r: 58,
    dot: 9,
    base: 2.8,
    offset: 80,
    track: "border-sky-200/80 dark:border-sky-400/15",
    dotCls:
      "bg-sky-500 dark:bg-sky-400 shadow-lg shadow-sky-500/40 dark:shadow-sky-400/30",
  },
] as const;

const MESSAGES = [
  "Connecting to the sync engine",
  "Fetching your latest changes",
  "Reconciling offline edits",
  "Verifying workspace integrity",
  "Warming up the cache",
];

const DENSITY_OPTIONS: Density[] = ["minimal", "balanced", "dense"];

const PlayIcon = (
  <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
    <path d="M6 4.5v11a1 1 0 0 0 1.54.84l8.5-5.5a1 1 0 0 0 0-1.68l-8.5-5.5A1 1 0 0 0 6 4.5Z" />
  </svg>
);

const PauseIcon = (
  <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
    <path d="M6 4h3v12H6zM11 4h3v12h-3z" />
  </svg>
);

const RestartIcon = (
  <svg
    viewBox="0 0 20 20"
    className="h-4 w-4"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.8}
    aria-hidden="true"
  >
    <path d="M15.6 5.6A6 6 0 1 0 16.9 12" strokeLinecap="round" />
    <path d="M16 3.4V6h-2.6" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

export default function LoadOrbit() {
  const prefersReduced = useReducedMotion();
  const reduced = Boolean(prefersReduced);

  const [running, setRunning] = useState(true);
  const [speed, setSpeed] = useState(1);
  const [density, setDensity] = useState<Density>("balanced");
  const [reverse, setReverse] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const [msgIndex, setMsgIndex] = useState(0);

  const speedId = useId();
  const densityName = useId();

  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => setElapsed((e) => e + 1), 1000);
    return () => window.clearInterval(id);
  }, [running]);

  useEffect(() => {
    if (!running || reduced) return;
    const id = window.setInterval(
      () => setMsgIndex((i) => (i + 1) % MESSAGES.length),
      2800,
    );
    return () => window.clearInterval(id);
  }, [running, reduced]);

  const handleRestart = () => {
    setElapsed(0);
    setMsgIndex(0);
    setRunning(true);
  };

  const playState: "running" | "paused" = running ? "running" : "paused";
  const mm = Math.floor(elapsed / 60);
  const ss = elapsed % 60;
  const time = `${mm}:${String(ss).padStart(2, "0")}`;
  const count = DOTS_PER_RING[density];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        .lo-anim {
          animation-name: lo-spin;
          animation-timing-function: linear;
          animation-iteration-count: infinite;
          will-change: transform;
        }
        .lo-arm {
          position: absolute;
          top: 0;
          left: 50%;
          width: 0;
          height: 50%;
          transform-origin: bottom center;
        }
        .lo-dot {
          position: absolute;
          top: 0;
          left: 50%;
          transform: translate(-50%, -50%);
        }
        .lo-core-anim {
          animation: lo-pulse 2s ease-in-out infinite;
        }
        .lo-bar-anim {
          animation-name: lo-bar;
          animation-timing-function: ease-in-out;
          animation-iteration-count: infinite;
        }
        @keyframes lo-spin {
          to { transform: rotate(360deg); }
        }
        @keyframes lo-pulse {
          0%, 100% { transform: scale(1); opacity: 1; }
          50% { transform: scale(1.35); opacity: 0.6; }
        }
        @keyframes lo-bar {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(360%); }
        }
        @media (prefers-reduced-motion: reduce) {
          .lo-anim, .lo-core-anim, .lo-bar-anim {
            animation: none !important;
          }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-10 max-w-2xl">
          <span className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Loaders · Orbit
          </span>
          <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
            Orbiting dots loader
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A lightweight, indeterminate loader built from three counter-rotating
            rings. Pause it, change the tempo, or add more dots — every control
            below drives the animation in real time.
          </p>
        </div>

        <div className="grid gap-6 lg:grid-cols-2">
          {/* Loader panel */}
          <div className="flex flex-col items-center justify-center rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div
              aria-hidden="true"
              className="relative mx-auto"
              style={{ width: 300, height: 300 }}
            >
              <div className="absolute inset-0 rounded-full bg-[radial-gradient(circle,rgba(99,102,241,0.14),transparent_68%)] dark:bg-[radial-gradient(circle,rgba(129,140,248,0.16),transparent_68%)]" />

              {RINGS.map((ring, i) => {
                const D = ring.r * 2;
                return (
                  <div
                    key={`track-${i}`}
                    className={`absolute rounded-full border ${ring.track}`}
                    style={{
                      left: "50%",
                      top: "50%",
                      width: D,
                      height: D,
                      marginLeft: -ring.r,
                      marginTop: -ring.r,
                    }}
                  />
                );
              })}

              {RINGS.map((ring, i) => {
                const D = ring.r * 2;
                const dur = ring.base / speed;
                const dir: "normal" | "reverse" =
                  (i % 2 === 1) !== reverse ? "reverse" : "normal";
                return (
                  <div
                    key={`ring-${i}`}
                    className={reduced ? "absolute" : "lo-anim absolute"}
                    style={{
                      left: "50%",
                      top: "50%",
                      width: D,
                      height: D,
                      marginLeft: -ring.r,
                      marginTop: -ring.r,
                      animationDuration: `${dur}s`,
                      animationDirection: dir,
                      animationPlayState: playState,
                    }}
                  >
                    {Array.from({ length: count }).map((_, k) => {
                      const angle = k * (360 / count) + ring.offset;
                      return (
                        <span
                          key={k}
                          className="lo-arm"
                          style={{ transform: `rotate(${angle}deg)` }}
                        >
                          <span
                            className={`lo-dot rounded-full ${ring.dotCls}`}
                            style={{ width: ring.dot, height: ring.dot }}
                          />
                        </span>
                      );
                    })}
                  </div>
                );
              })}

              <div
                className={`absolute rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/40 dark:bg-emerald-400 dark:shadow-emerald-400/30 ${
                  reduced ? "" : "lo-core-anim"
                }`}
                style={{
                  left: "50%",
                  top: "50%",
                  width: 18,
                  height: 18,
                  marginLeft: -9,
                  marginTop: -9,
                  animationPlayState: playState,
                }}
              />
            </div>

            <span className="sr-only" role="status" aria-live="polite">
              {running
                ? `Loading. ${MESSAGES[msgIndex]}. ${time} elapsed.`
                : "Loading paused."}
            </span>

            {/* Status readout */}
            <div className="mt-8 w-full max-w-xs">
              <div className="flex items-center justify-between text-sm">
                <p
                  className="font-medium text-slate-700 dark:text-slate-200"
                  aria-hidden="true"
                >
                  {MESSAGES[msgIndex]}
                </p>
                <span className="tabular-nums text-slate-400 dark:text-slate-500">
                  {time}
                </span>
              </div>
              <div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                <div
                  className={`h-full w-1/3 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 ${
                    reduced ? "" : "lo-bar-anim"
                  }`}
                  style={{
                    animationDuration: `${1.6 / speed}s`,
                    animationPlayState: playState,
                  }}
                />
              </div>
              <p className="mt-3 text-xs text-slate-400 dark:text-slate-500">
                This usually takes a few seconds.
              </p>
            </div>
          </div>

          {/* Controls panel */}
          <div className="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Playground
            </h3>

            <div className="mt-4 flex flex-wrap gap-3">
              <button
                type="button"
                onClick={() => setRunning((r) => !r)}
                aria-pressed={running}
                className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900"
              >
                {running ? PauseIcon : PlayIcon}
                {running ? "Pause" : "Play"}
              </button>

              <button
                type="button"
                onClick={handleRestart}
                className="inline-flex items-center gap-2 rounded-xl border border-slate-300 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-indigo-500 focus-visible:ring-offset-2 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                {RestartIcon}
                Restart
              </button>
            </div>

            {/* Speed */}
            <div className="mt-8">
              <div className="flex items-center justify-between">
                <label
                  htmlFor={speedId}
                  className="text-sm font-medium text-slate-700 dark:text-slate-200"
                >
                  Speed
                </label>
                <span className="tabular-nums text-sm text-slate-500 dark:text-slate-400">
                  {speed.toFixed(2)}×
                </span>
              </div>
              <input
                id={speedId}
                type="range"
                min={0.25}
                max={2.5}
                step={0.25}
                value={speed}
                onChange={(e) => setSpeed(Number(e.target.value))}
                className="mt-3 w-full cursor-pointer rounded accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            {/* Density */}
            <fieldset className="mt-8">
              <legend className="text-sm font-medium text-slate-700 dark:text-slate-200">
                Dot density
              </legend>
              <div className="mt-3 grid grid-cols-3 gap-2">
                {DENSITY_OPTIONS.map((opt) => (
                  <label key={opt} className="relative cursor-pointer">
                    <input
                      type="radio"
                      name={densityName}
                      value={opt}
                      checked={density === opt}
                      onChange={() => setDensity(opt)}
                      className="peer sr-only"
                    />
                    <span className="block rounded-xl border border-slate-300 bg-white px-3 py-2 text-center text-sm font-medium capitalize text-slate-600 transition peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-300 dark:peer-focus-visible:ring-offset-slate-900">
                      {opt}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            {/* Reverse toggle */}
            <label className="mt-8 flex cursor-pointer items-center justify-between gap-4">
              <span className="text-sm font-medium text-slate-700 dark:text-slate-200">
                Reverse alternate rings
              </span>
              <span className="relative inline-flex shrink-0">
                <input
                  type="checkbox"
                  role="switch"
                  checked={reverse}
                  onChange={(e) => setReverse(e.target.checked)}
                  className="peer sr-only"
                />
                <span className="h-6 w-11 rounded-full bg-slate-300 transition-colors peer-checked:bg-indigo-500 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 dark:bg-slate-700 dark:peer-checked:bg-indigo-400 dark:peer-focus-visible:ring-offset-slate-900" />
                <span className="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" />
              </span>
            </label>

            <p className="mt-8 rounded-xl bg-slate-50 px-4 py-3 text-xs leading-relaxed text-slate-500 dark:bg-slate-800/60 dark:text-slate-400">
              Motion respects your system's reduced-motion setting — when enabled,
              the rings hold a static diagram and the live announcements stay
              quiet.
            </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 →