Web InnoventixFreeCode

Animated SVG progress rings

Original · free

Four 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.

byWeb InnoventixReact + Tailwind
animated progress ringsvg circular progressframer motion statsradial percentage statsstroke dashoffset animationcount up ring
Open

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
astat-progress-rings.tsx
"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

motion

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 quote

Similar components

Browse all →
Four Stat Metric Band

Four Stat Metric Band

Original

A 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

Labelled Stats With Dividers

Original

Three centred stats separated by responsive dividers, each pairing a headline figure with an uppercase label and supporting detail.

Trusted By Logo Cloud

Trusted By Logo Cloud

Original

A 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

Hero Stat With Supporting Copy

Original

A two-column layout pairing one oversized hero statistic with a supporting paragraph and a checklist card explaining the number.

Count-up stats reveal

Count-up stats reveal

Original

A 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

Staggered stat band with marquee ticker

Original

A 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

Spotlight Hero

Original

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

Split Hero

Split Hero

Original

A two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero

Gradient Spotlight Hero

Original

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

App Preview Hero

App Preview Hero

Original

A 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

Waitlist Capture Hero

Original

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

Image Backdrop Hero

Image Backdrop Hero

Original

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