Animated SVG progress rings
Original · freeFour circular SVG progress rings that draw their gradient arcs and count up to their target percentage the moment they enter view, over a slowly rotating conic halo with a soft glow pulse behind each ring.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/astat-progress-rings.json"use client";
import { useEffect, useRef, useState } from "react";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
type Ring = {
id: string;
value: number;
label: string;
hint: string;
from: string;
to: string;
};
const rings: Ring[] = [
{ id: "astat2-a", value: 92, label: "Task success", hint: "flows completed first try", from: "#6366f1", to: "#a855f7" },
{ id: "astat2-b", value: 78, label: "Automation", hint: "manual steps removed", from: "#0ea5e9", to: "#22d3ee" },
{ id: "astat2-c", value: 64, label: "Adoption", hint: "seats active this month", from: "#f59e0b", to: "#f43f5e" },
{ id: "astat2-d", value: 88, label: "Retention", hint: "teams renewing yearly", from: "#10b981", to: "#84cc16" },
];
const SIZE = 148;
const STROKE = 12;
const RADIUS = (SIZE - STROKE) / 2;
const CIRC = 2 * Math.PI * RADIUS;
function ProgressRing({ ring }: { ring: Ring }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-60px" });
const reduce = useReducedMotion();
const [count, setCount] = useState(0);
const target = ring.value;
const offset = CIRC - (target / 100) * CIRC;
useEffect(() => {
if (!inView) return;
if (reduce) {
setCount(target);
return;
}
const controls = animate(0, target, {
duration: 1.9,
ease: [0.16, 1, 0.3, 1],
onUpdate: (v) => setCount(v),
});
return () => controls.stop();
}, [inView, reduce, target]);
return (
<div ref={ref} className="flex flex-col items-center text-center">
<div className="relative" style={{ width: SIZE, height: SIZE }}>
{/* soft glow pulse behind the ring */}
<motion.div
aria-hidden
className="absolute inset-2 rounded-full blur-xl"
style={{ background: `radial-gradient(circle, ${ring.to}55, transparent 70%)` }}
animate={reduce ? undefined : { opacity: [0.5, 0.9, 0.5], scale: [0.92, 1.04, 0.92] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
/>
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} className="relative -rotate-90">
<defs>
<linearGradient id={ring.id} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={ring.from} />
<stop offset="100%" stopColor={ring.to} />
</linearGradient>
</defs>
{/* track */}
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
strokeWidth={STROKE}
className="stroke-zinc-200 dark:stroke-zinc-800"
/>
{/* animated progress arc */}
<motion.circle
cx={SIZE / 2}
cy={SIZE / 2}
r={RADIUS}
fill="none"
stroke={`url(#${ring.id})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={CIRC}
initial={{ strokeDashoffset: CIRC }}
animate={inView ? { strokeDashoffset: offset } : { strokeDashoffset: CIRC }}
transition={reduce ? { duration: 0 } : { duration: 1.9, ease: [0.16, 1, 0.3, 1] }}
/>
</svg>
{/* centre count-up */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-3xl font-bold tabular-nums text-zinc-900 dark:text-white">
{Math.round(count)}
<span className="text-lg align-top text-zinc-400 dark:text-zinc-500">%</span>
</span>
</div>
</div>
<p className="mt-5 text-base font-semibold text-zinc-900 dark:text-white">{ring.label}</p>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{ring.hint}</p>
</div>
);
}
export default function AstatProgressRings() {
const reduce = useReducedMotion();
return (
<section className="relative isolate overflow-hidden bg-zinc-50 px-6 py-20 dark:bg-zinc-950 md:py-28">
<style>{`
@keyframes astat2-spin { to { transform: rotate(360deg); } }
`}</style>
{/* slow rotating conic halo */}
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 h-[560px] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-40 blur-3xl [animation:astat2-spin_36s_linear_infinite] motion-reduce:animate-none dark:opacity-30"
style={{
background:
"conic-gradient(from 0deg, #6366f133, #22d3ee33, #f59e0b33, #10b98133, #6366f133)",
}}
/>
<div className="relative mx-auto max-w-6xl">
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="mx-auto max-w-2xl text-center"
>
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Performance snapshot
</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
Progress you can watch fill in
</h2>
<p className="mt-3 text-pretty text-zinc-600 dark:text-zinc-400">
Each ring draws its arc and counts to target the instant the section scrolls into view.
</p>
</motion.div>
<motion.div
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-60px" }}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.14 } } }}
className="mt-16 grid grid-cols-1 gap-y-12 sm:grid-cols-2 lg:grid-cols-4"
>
{rings.map((ring) => (
<motion.div
key={ring.id}
variants={{
hidden: reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.94 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 120, damping: 18 },
},
}}
whileHover={reduce ? undefined : { y: -6 }}
>
<ProgressRing ring={ring} />
</motion.div>
))}
</motion.div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Four Stat Metric Band
OriginalA four-column metric band with gradient figures inside a hairline-divided card, ideal for headline numbers like uptime, latency and growth.

Labelled Stats With Dividers
OriginalThree centred stats separated by responsive dividers, each pairing a headline figure with an uppercase label and supporting detail.

Trusted By Logo Cloud
OriginalA responsive six-up logo strip using simple inline-SVG marks and wordmarks with a grayscale-to-colour hover, for a trusted-by section.

Hero Stat With Supporting Copy
OriginalA two-column layout pairing one oversized hero statistic with a supporting paragraph and a checklist card explaining the number.

Count-up stats reveal
OriginalA four-card stat band whose gradient numbers count up from zero as the section scrolls into view, with staggered blur-in cards, a pulsing eyebrow, floating aurora blobs and a shimmering underline sweep.

Staggered stat band with marquee ticker
OriginalA dark stat band where 3D-tilt cards stagger into view with count-up figures over an animated gradient mesh, finished by an infinite marquee ticker of highlight chips that pauses on hover.

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

Image Backdrop Hero
OriginalA cinematic full-bleed hero with a layered image-style backdrop, overlaid left-aligned copy and a trusted-by logos strip.

