A dark stats section with four large rings, each in its own gradient. When the section scrolls into view the rings draw clockwise one after another, a bright dot rides the tip of each stroke, and the number in the middle counts up in step with the arc. After they land, a soft glow breathes under each ring and a dotted orbit turns slowly. A Replay button runs the entrance again. Rings sit four across on desktop and two by two on phones.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
const DRAW_SECONDS = 1.25;
const STAGGER_SECONDS = 0.1;
const stats = [
{ label: "Delivered on time", note: "Target for the year was 97%", value: 98.2, decimals: 1, unit: "%", fill: 0.982, from: "#bef264", to: "#22c55e" },
{ label: "Average driver rating", note: "From 61,400 customer reviews", value: 4.8, decimals: 1, unit: "/ 5", fill: 0.96, from: "#67e8f9", to: "#3b82f6" },
{ label: "Postcodes with same-day", note: "Up from 64% twelve months ago", value: 86, decimals: 0, unit: "%", fill: 0.86, from: "#f9a8d4", to: "#e11d48" },
{ label: "Routes driven electric", note: "Every new van since March", value: 72, decimals: 0, unit: "%", fill: 0.72, from: "#fde68a", to: "#f97316" },
];
type Stat = (typeof stats)[number];
// The server render shows the finished rings; the browser empties them before the first paint.
const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
function Ring({ stat, index, run, still }: { stat: Stat; index: number; run: number; still: boolean }) {
const progress = useMotionValue(1);
useBeforePaint(() => {
if (!still) progress.set(0);
}, [still, progress]);
useEffect(() => {
if (still) {
progress.set(1);
return;
}
if (run === 0) return;
progress.set(0);
const controls = animate(progress, 1, { duration: DRAW_SECONDS, delay: index * STAGGER_SECONDS, ease: [0.22, 1, 0.36, 1] });
return () => controls.stop();
}, [run, still, index, progress]);
const arc = useTransform(progress, (p) => p * stat.fill);
const turn = useTransform(progress, (p) => p * stat.fill * 360);
const text = useTransform(progress, (p) => (p * stat.value).toFixed(stat.decimals));
const headOpacity = useTransform(progress, [0, 0.08, 1], [0, 1, 1]);
const id = `stats-rings-gradient-${index}`;
return (
<li className="flex flex-col items-center text-center">
<div className="relative aspect-square w-full max-w-[230px]">
<svg viewBox="0 0 200 200" className="h-full w-full -rotate-90 overflow-visible" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor={stat.from} />
<stop offset="1" stopColor={stat.to} />
</linearGradient>
</defs>
<circle cx="100" cy="100" r="97" fill="none" stroke="#fff" strokeOpacity="0.14" strokeWidth="1" strokeDasharray="1 7" className="stats-rings-orbit" />
<circle cx="100" cy="100" r="80" fill="none" stroke="#fff" strokeOpacity="0.07" strokeWidth="13" />
<motion.circle cx="100" cy="100" r="80" fill="none" stroke={`url(#${id})`} strokeWidth="13" strokeLinecap="round" className="stats-rings-glow blur-md" style={{ pathLength: arc }} />
<motion.circle cx="100" cy="100" r="80" fill="none" stroke={`url(#${id})`} strokeWidth="13" strokeLinecap="round" style={{ pathLength: arc }} />
</svg>
{/* The bright head of the stroke: a dot on a square that turns with the arc. */}
<motion.div aria-hidden="true" className="absolute inset-0" style={{ rotate: turn, opacity: headOpacity }}>
<span className="absolute left-1/2 top-[10%] flex h-2 w-2 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<span className="absolute h-full w-full animate-ping rounded-full bg-white opacity-60 motion-reduce:animate-none" />
<span className="h-full w-full rounded-full bg-white" />
</span>
</motion.div>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<p className="flex items-baseline gap-1 text-white">
<motion.span className="text-4xl font-semibold tabular-nums tracking-tight sm:text-5xl">{text}</motion.span>
<span className="text-sm font-medium text-zinc-400 sm:text-base">{stat.unit}</span>
</p>
</div>
</div>
<p className="mt-5 text-base font-semibold text-white">{stat.label}</p>
<p className="mt-1 text-sm text-zinc-400">{stat.note}</p>
</li>
);
}
export default function StatsSectionWithRadialProgressRings() {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.3, once: true });
const still = !!useReducedMotion();
const [run, setRun] = useState(0);
useEffect(() => {
if (inView) setRun((r) => (r === 0 ? 1 : r));
}, [inView]);
return (
<section ref={ref} className="relative overflow-hidden bg-zinc-950 px-5 py-16 text-zinc-200 sm:px-6 sm:py-24">
<style>{`
@keyframes stats-rings-orbit { to { transform: rotate(360deg); } }
@keyframes stats-rings-breathe { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.8; } }
.stats-rings-orbit { transform-origin: 100px 100px; animation: stats-rings-orbit 40s linear infinite; }
.stats-rings-glow { animation: stats-rings-breathe 3.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .stats-rings-orbit, .stats-rings-glow { animation: none; } .stats-rings-glow { opacity: 0.5; } }
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<div className="relative mx-auto max-w-6xl">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-lime-300">Tern Parcel, year in review</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">4.1 million doorsteps. Four numbers we watch.</h2>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex min-h-[44px] items-center gap-2 self-start rounded-full border border-white/15 px-5 text-sm font-medium text-white outline-none transition hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-lime-300 sm:self-auto"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
<path d="M4 10a6 6 0 1 0 2-4.5" />
<path d="M4 3.5v3h3" />
</svg>
Replay
</button>
</div>
<ul className="mt-14 grid grid-cols-2 gap-x-6 gap-y-12 lg:grid-cols-4 lg:gap-x-10">
{stats.map((s, i) => (
<Ring key={s.label} stat={s} index={i} run={run} still={still} />
))}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/stats-section-with-radial-progress-rings.json
Draw a circle with a stroke and no fill, then animate how much of the stroke is visible. Here framer-motion's `pathLength` does that: 0 shows nothing and 1 shows the whole circle. The SVG is rotated by minus 90 degrees so the stroke starts at twelve o'clock.
Both read one motion value that runs from 0 to 1. The arc length is that value times `fill`, the number is that value times `value`, and the dot's rotation is the same fraction of 360 degrees. One animation, three outputs.
A second copy of the arc sits underneath with a blur class and a slow opacity keyframe. Because it uses the same gradient and the same `pathLength`, the glow always matches the colour and the length of the stroke above it.
Yes. The driver rating shows 4.8 out of 5 with a `fill` of 0.96. The displayed number and the drawn amount are separate fields, so you decide what full means.
The server render and the copyable HTML show the final numbers so nothing reads as zero without scripts. In the browser the rings are emptied before the first paint and then drawn.
Related: Stats Band with Four Numbers on Dark, Stat Tiles with Sparkline Trends, Big Headline Hero with Stats Row.
More content sections or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.