Orbit Loader
An SVG loader with up to three counter-rotating orbits. Leave progress empty for an indeterminate spinner, or pass 0–100 and the outer orbit becomes a progress ring with a live percentage. It inherits text color, so it works on any surface and in both themes.
import type { CSSProperties } from "react";
export type OrbitLoaderSize = "sm" | "md" | "lg";
export interface OrbitLoaderProps {
/** Preset size or an exact pixel value. */
size?: OrbitLoaderSize | number;
/** Number of concentric orbits (1–3). */
orbits?: 1 | 2 | 3;
/** Seconds for the outer orbit to complete one revolution. */
speed?: number;
/** Arc + head color. Any CSS color; defaults to the current text color. */
color?: string;
/** Track color. Defaults to `color` at low opacity. */
trackColor?: string;
/** 0–100. When set, the outer orbit becomes a determinate progress ring. */
progress?: number;
/** Accessible label, also used as the visible caption. */
label?: string;
/** Render the label (and percentage, if determinate) under the loader. */
showLabel?: boolean;
/** Freeze the animation without unmounting (e.g. while a request is queued). */
paused?: boolean;
className?: string;
}
const SIZE_MAP: Record<OrbitLoaderSize, number> = { sm: 32, md: 48, lg: 72 };
const RADII = [44, 31, 18] as const;
const ARC_FRACTION = [0.28, 0.36, 0.5] as const;
const STROKE = 5;
const KEYFRAMES = `
@keyframes ls-orbit-spin { to { transform: rotate(360deg); } }
.ls-orbit-spin { transform-origin: 50px 50px; animation: ls-orbit-spin var(--ls-orbit-d, 1.6s) linear infinite; }
@media (prefers-reduced-motion: reduce) {
.ls-orbit-spin { animation-duration: calc(var(--ls-orbit-d, 1.6s) * 4); }
}`;
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
function pointOnCircle(radius: number, fraction: number) {
const angle = fraction * Math.PI * 2;
return { x: 50 + radius * Math.cos(angle), y: 50 + radius * Math.sin(angle) };
}
export function OrbitLoader({
size = "md",
orbits = 3,
speed = 1.6,
color = "currentColor",
trackColor,
progress,
label = "Loading",
showLabel = false,
paused = false,
className,
}: OrbitLoaderProps) {
const px = typeof size === "number" ? size : SIZE_MAP[size];
const count = Math.min(3, Math.max(1, Math.round(orbits)));
const determinate = typeof progress === "number" && Number.isFinite(progress);
const value = determinate ? Math.min(100, Math.max(0, progress)) : 0;
const track = trackColor ?? color;
const caption = determinate ? `${label} · ${Math.round(value)}%` : label;
const a11y = determinate
? {
role: "progressbar" as const,
"aria-label": label,
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-valuenow": Math.round(value),
}
: { role: "status" as const, "aria-live": "polite" as const };
return (
<div {...a11y} className={cn("inline-flex flex-col items-center gap-2", className)}>
<style href="ls-orbit-loader" precedence="default">
{KEYFRAMES}
</style>
<svg
width={px}
height={px}
viewBox="0 0 100 100"
fill="none"
aria-hidden="true"
focusable="false"
className="shrink-0 overflow-visible"
>
{RADII.slice(0, count).map((r, i) => {
const circumference = 2 * Math.PI * r;
const isProgressRing = determinate && i === 0;
const reverse = i % 2 === 1;
const spinStyle = {
"--ls-orbit-d": `${speed * (1 + i * 0.45)}s`,
animationDirection: reverse ? "reverse" : "normal",
animationPlayState: paused ? "paused" : "running",
} as CSSProperties;
if (isProgressRing) {
const head = pointOnCircle(r, value / 100);
return (
<g key={r}>
<circle cx={50} cy={50} r={r} stroke={track} strokeOpacity={0.15} strokeWidth={STROKE} />
<g transform="rotate(-90 50 50)">
<circle
cx={50}
cy={50}
r={r}
stroke={color}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - value / 100)}
style={{ transition: "stroke-dashoffset 400ms ease" }}
/>
<circle cx={head.x} cy={head.y} r={STROKE * 0.95} fill={color} />
</g>
</g>
);
}
const arc = circumference * ARC_FRACTION[i];
const head = reverse ? pointOnCircle(r, 0) : pointOnCircle(r, ARC_FRACTION[i]);
return (
<g key={r}>
<circle cx={50} cy={50} r={r} stroke={track} strokeOpacity={0.15} strokeWidth={STROKE} />
<g className="ls-orbit-spin" style={spinStyle}>
<circle
cx={50}
cy={50}
r={r}
stroke={color}
strokeOpacity={1 - i * 0.2}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={`${arc} ${circumference}`}
/>
<circle cx={head.x} cy={head.y} r={STROKE * 0.95} fill={color} fillOpacity={1 - i * 0.2} />
</g>
</g>
);
})}
</svg>
{showLabel ? (
<span className="text-xs font-medium tabular-nums opacity-80">{caption}</span>
) : (
<span className="sr-only">{caption}</span>
)}
</div>
);
}Usage
import { OrbitLoader } from "@/components/gallery/orbit-loader";
// Indeterminate — inherits the text color
<OrbitLoader className="text-indigo-500" label="Fetching orders" />
// Determinate — outer orbit becomes a progress ring
<OrbitLoader size="lg" progress={uploaded} label="Uploading" showLabel />
// Custom color, 2 orbits, slower, paused while queued
<OrbitLoader orbits={2} speed={2.4} color="#f59e0b" paused={isQueued} />States
Indeterminate (1, 2 or 3 orbits) · Determinate progress 0–100% · Paused · Sizes: sm, md, lg, custom px · Reduced motion (4× slower)
Props
- size= "md"
"sm" | "md" | "lg" | number
Preset (32 / 48 / 72px) or an exact pixel size.
- orbits= 3
1 | 2 | 3
How many concentric orbits to render.
- speed= 1.6
number
Seconds per revolution of the outer orbit. Inner orbits run progressively slower.
- color= "currentColor"
string
Arc and head color. Any CSS color.
- trackColor= color
string
Track color, rendered at 15% opacity.
- progress
number
0–100. Switches the outer orbit to a determinate ring and the role to progressbar.
- label= "Loading"
string
Accessible name. Also the visible caption when showLabel is on.
- showLabel= false
boolean
Show the caption (and percentage) under the loader.
- paused= false
boolean
Freeze the animation without unmounting.
- className
string
Extra classes for the wrapper — e.g. a text color.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | number | "md" | Preset (32 / 48 / 72px) or an exact pixel size. |
| orbits | 1 | 2 | 3 | 3 | How many concentric orbits to render. |
| speed | number | 1.6 | Seconds per revolution of the outer orbit. Inner orbits run progressively slower. |
| color | string | "currentColor" | Arc and head color. Any CSS color. |
| trackColor | string | color | Track color, rendered at 15% opacity. |
| progress | number | — | 0–100. Switches the outer orbit to a determinate ring and the role to progressbar. |
| label | string | "Loading" | Accessible name. Also the visible caption when showLabel is on. |
| showLabel | boolean | false | Show the caption (and percentage) under the loader. |
| paused | boolean | false | Freeze the animation without unmounting. |
| className | string | — | Extra classes for the wrapper — e.g. a text color. |
Accessibility
- Indeterminate mode uses role="status" with a polite live region and a screen-reader label.
- Determinate mode uses role="progressbar" with aria-valuenow / min / max.
- The SVG is aria-hidden; the label carries the meaning.
- prefers-reduced-motion slows the rotation 4× instead of removing the feedback.
Build prompt
The final prompt used to build this component
Build a reusable React + TypeScript + Tailwind loader component called OrbitLoader.
Visual: an SVG (viewBox 100x100) with up to 3 concentric orbits (radii 44, 31, 18). Each orbit has a faint track (15% opacity) and a rounded arc segment with a solid "head" dot at its leading edge. Orbits rotate at different speeds and alternate direction so it feels like a small solar system.
Props (typed interface): size ("sm" | "md" | "lg" | number px), orbits (1–3), speed (seconds per outer revolution), color (default currentColor so it inherits text color), trackColor, progress (0–100), label, showLabel, paused, className.
Behaviour:
- No progress → indeterminate: role="status", aria-live polite, sr-only label.
- progress given → the outer orbit becomes a determinate progress ring (stroke-dashoffset, smooth transition) with the head dot at the end of the progress; role="progressbar" with aria-valuenow/min/max; caption shows "Label · 42%".
- paused freezes animation via animation-play-state.
- prefers-reduced-motion slows the animation 4x instead of removing it.
- Keep it self-contained: keyframes ship with the component via a React 19 <style href precedence> tag, no global CSS or dependencies.
- Clamp progress to 0–100 and orbits to 1–3. No hardcoded colors or sizes.