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.