Feedback & Data
ProgressOrbit
A ring that fills on a spring with a glowing head at its tip, while a small moon circles outside until the job is done. Leave value out and it spins.
Download
- React
- Tailwind CSS v4
- motion
components/ui/progress-orbit.tsx
"use client";
import { useEffect, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type ProgressOrbitProps = {
/** 0 to 100. Leave out while the amount is unknown; the ring then spins. */
value?: number;
/** Name for screen readers. */
label?: string;
/** Width and height in pixels. */
size?: number;
/** Ring thickness in pixels. */
stroke?: number;
/** Ring colour. */
accent?: string;
/** Centre content. Replaces the percent. */
children?: ReactNode;
};
/* ProgressOrbit: a ring that fills on a spring with a glowing head at its tip, while a small moon circles outside until the job is done. */
export function ProgressOrbit({ value, label = "Progress", size = 120, stroke = 8, accent = "#a78bfa", children }: ProgressOrbitProps) {
const reduce = useReducedMotion();
const unknown = value === undefined;
const target = Math.min(100, Math.max(0, value ?? 25));
const done = target === 100;
const raw = useMotionValue(target);
const spring = useSpring(raw, { stiffness: 90, damping: 20 });
const shown = reduce ? raw : spring;
const length = useTransform(shown, (p) => p / 100);
const turn = useTransform(shown, (p) => p * 3.6);
const percent = useTransform(shown, (p) => `${Math.round(p)}%`);
const radius = size / 2 - stroke;
const head = stroke * 1.5;
useEffect(() => {
raw.set(target);
}, [raw, target]);
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={unknown ? undefined : Math.round(target)}
style={{ width: size, height: size, "--po": accent } as CSSProperties}
className="relative grid shrink-0 place-items-center"
>
<motion.div
className="absolute inset-0"
animate={{ rotate: unknown && !reduce ? 360 : 0 }}
transition={unknown ? { duration: 1.2, ease: "linear", repeat: Infinity } : { duration: 0 }}
>
<svg aria-hidden viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90 text-(--po)">
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="currentColor" strokeOpacity={0.15} strokeWidth={stroke} />
<motion.circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" style={{ pathLength: length }} />
</svg>
<motion.span aria-hidden className="absolute inset-0" style={{ rotate: turn }}>
<span
style={{ width: head, height: head, top: stroke - head / 2, marginLeft: -head / 2 }}
className="absolute left-1/2 rounded-full bg-white shadow-[0_0_12px_2px_var(--po)]"
/>
</motion.span>
</motion.div>
{!done && !reduce && (
<motion.span aria-hidden className="absolute inset-0" animate={{ rotate: 360 }} transition={{ duration: 2.6, ease: "linear", repeat: Infinity }}>
<span className="absolute top-0 left-1/2 -ml-[2px] h-1 w-1 rounded-full bg-(--po)" />
</motion.span>
)}
<AnimatePresence>
{done && !reduce && (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full border-2 border-(--po)"
initial={{ scale: 0.9, opacity: 0.7 }}
animate={{ scale: 1.25, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
)}
</AnimatePresence>
<span className={`relative font-semibold tabular-nums ${done ? "text-(--po)" : "text-white"}`} style={{ fontSize: size * 0.2 }}>
{children ?? (unknown ? null : <motion.span>{percent}</motion.span>)}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/progress-orbit.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/progress-orbit-demo.tsx
"use client"; import { useEffect, useState } from "react"; import { ProgressOrbit } from "@/components/ui/progress-orbit"; export default function ProgressOrbitExample() { const [value, setValue] = useState(0); // Fake upload: climbs past 100 so the ring rests full for a moment, then starts over. useEffect(() => { const id = setInterval(() => setValue((v) => (v >= 148 ? 0 : v + 12)), 900); return () => clearInterval(id); }, []); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="flex items-center gap-8"> <ProgressOrbit value={Math.min(100, value)} label="Upload" /> <ProgressOrbit label="Loading" size={64} stroke={5} accent="#34d399" /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| value | number | — | 0 to 100. Leave out while the amount is unknown. |
| label | string | "Progress" | Name for screen readers. |
| size | number | 120 | Width and height in pixels. |
| stroke | number | 8 | Ring thickness in pixels. |
| accent | string | "#a78bfa" | Ring colour. |
| children | ReactNode | — | Centre content. Replaces the percent. |