Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:36Get Lifetime →
loopkit
freeNew185 lines · framer-motion

Loader Orbit

A small ring of motion around a central mark. A full-width showcase section for an orbit-style loading animation: a central dot holds its place while 1-4 small dots circle it at staggered radii and speeds, plus a Replay button that restarts the loop from frame 0.

Use it for
Showing off a loading state on a docs/marketing page, or as the live reference a team copies the animation out of for their own spinner.
Themes
Light + dark
License
Commercial use
Live

Loader

A loader that feels worth the wait

A central mark holds its place while a small ring of dots circles it at staggered radii and speeds — a lightweight loading state that still carries some motion design.

tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width showcase section for an "orbit" loading animation: a central
 * mark with a handful of small dots circling it at different radii and
 * speeds, driven by plain CSS keyframes. Loops continuously while visible,
 * pauses on scroll-away (IntersectionObserver) and when the tab is hidden,
 * sits fully static under prefers-reduced-motion, and a Replay button
 * remounts the animated ring to restart it from frame 0.
 */

export type LoaderOrbitColor = "signal" | "info" | "lime" | "ok";

export interface LoaderOrbitProps {
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Number of dots orbiting the central mark. */
  dotCount?: number;
  /** Orbit speed multiplier. */
  speed?: number;
  /** Diameter in px of the whole orbit assembly at the base scale. */
  size?: number;
  color?: LoaderOrbitColor;
  className?: string;
}

const COLOR_BG: Record<LoaderOrbitColor, string> = {
  signal: "bg-signal",
  info: "bg-info",
  lime: "bg-lime",
  ok: "bg-ok",
};

function ReplayIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={className}>
      <path d="M3 12a9 9 0 1 0 3-6.7" />
      <path d="M3 4v5h5" />
    </svg>
  );
}

export function LoaderOrbit({
  eyebrow = "Loader",
  title = "A loader that feels worth the wait",
  description = "A central mark holds its place while a small ring of dots circles it at staggered radii and speeds — a lightweight loading state that still carries some motion design.",
  dotCount = 3,
  speed = 1,
  size = 112,
  color = "signal",
  className,
}: LoaderOrbitProps) {
  const reduce = useReducedMotionSafe();
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [visible, setVisible] = React.useState(true);
  const [tabHidden, setTabHidden] = React.useState(false);
  const [cycle, setCycle] = React.useState(0);

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
    io.observe(host);

    const onVisibility = () => setTabHidden(document.hidden);
    onVisibility();
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  const playing = visible && !tabHidden && !reduce;
  const safeDotCount = Math.min(4, Math.max(1, Math.round(dotCount)));
  const safeSpeed = Math.max(0.1, speed);

  const dotSize = Math.max(8, Math.round(size * 0.11));
  const centralSize = Math.round(size * 0.34);
  const baseRadius = size * 0.46;
  const radiusStep = size * 0.2;
  const maxRadius = baseRadius + radiusStep * (safeDotCount - 1);
  const outerSize = Math.round((maxRadius + dotSize) * 2 + 16);

  const rings = Array.from({ length: safeDotCount }, (_, i) => {
    const radius = baseRadius + radiusStep * i;
    const duration = (3.6 + i * 1.1) / safeSpeed;
    const reverse = i % 2 === 1;
    const opacity = 0.9 - i * 0.14;
    return { radius, duration, reverse, opacity };
  });

  return (
    <section className={cn("relative w-full overflow-hidden bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
      <div className="mx-auto max-w-2xl text-center">
        <p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
        <h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">
          {title}
        </h2>
        <p className="mx-auto mt-4 max-w-lg text-base leading-relaxed text-muted-foreground">{description}</p>
      </div>

      <div
        ref={hostRef}
        role="status"
        aria-label="Loading animation preview"
        className="relative mx-auto mt-14 grid place-items-center"
        style={{ minHeight: outerSize }}
      >
        <span
          aria-hidden
          className="absolute rounded-full border border-dashed border-border opacity-60"
          style={{ width: outerSize - dotSize, height: outerSize - dotSize }}
        />

        <div key={cycle} className="relative" style={{ width: outerSize, height: outerSize }} aria-hidden>
          {rings.map((ring, i) => (
            <div
              key={i}
              className={cn("absolute left-1/2 top-1/2", !reduce && "lk-orbit-spin")}
              style={{
                width: 0,
                height: 0,
                animationDuration: `${ring.duration}s`,
                animationDirection: ring.reverse ? "reverse" : "normal",
                animationPlayState: playing ? "running" : "paused",
              }}
            >
              <span
                className={cn("absolute rounded-full", COLOR_BG[color])}
                style={{
                  width: dotSize,
                  height: dotSize,
                  left: ring.radius - dotSize / 2,
                  top: -dotSize / 2,
                  opacity: ring.opacity,
                }}
              />
            </div>
          ))}

          <span
            className={cn("absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_10px_26px_-10px_rgb(0_0_0/0.4)]", COLOR_BG[color])}
            style={{ width: centralSize, height: centralSize }}
          />
        </div>
      </div>

      <div className="mt-10 flex justify-center">
        <button
          type="button"
          onClick={() => setCycle((c) => c + 1)}
          className="inline-flex h-11 items-center gap-2 rounded-full border-2 border-border px-5 text-sm font-semibold text-foreground transition hover:border-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
        >
          <ReplayIcon className="size-4" />
          Replay
        </button>
      </div>

      {!reduce && (
        <style>{`
          @keyframes lk-orbit-spin-key { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
          .lk-orbit-spin { animation-name: lk-orbit-spin-key; animation-timing-function: linear; animation-iteration-count: infinite; }
        `}</style>
      )}
    </section>
  );
}

export default LoaderOrbit;

How it behaves

  • →Each orbiting dot sits inside its own zero-size rotation anchor animated with a plain CSS @keyframes rotation; radius and duration both scale per dot index for staggered depth.
  • →dotCount (1-4) changes how many dots orbit; speed scales every ring's rotation duration; size scales the central mark, the dots and their radii together; color swaps all of it between the signal/info/lime/ok tokens.
  • →Loops continuously while the showcase is on screen, pauses via animation-play-state when an IntersectionObserver reports it has scrolled offscreen, and pauses again when the tab goes hidden (visibilitychange).
  • →The Replay button bumps a key on the animated ring, remounting it so the rotation restarts from its 0% keyframe.
  • →Under prefers-reduced-motion no CSS animation is ever created; the ring renders as a static settled arrangement and the Replay button has nothing to restart, so nothing auto-animates.

Accessibility

  • ✓The orbit assembly is `aria-hidden`; the showcase host carries `role="status"` with an `aria-label` describing it as a loading animation preview, so assistive tech gets one clear line instead of decorative dot elements.
  • ✓Replay is a real `<button>` with visible focus-ring styling from the design system, reachable and operable by keyboard.
  • ✓No motion starts or restarts on its own under `prefers-reduced-motion`; only the explicit Replay click ever changes the ring's key.
  • ✓Honours prefers-reduced-motion.