Skip to content

Circular Progress Ring

An SVG progress ring whose fill and centered percentage count up together on scroll into view.

Freeeditorialneon
Category
Components
Added
2026-08-22
Deps
1
Updated
2026-09-01
Preview
0%

Props

72
120
10
1.2
Requires framer-motion
"use client";

import { useEffect, useRef, useState } from "react";
import { useInView } from "framer-motion";

export function ProgressRing({
  target = 72,
  size = 120,
  strokeWidth = 10,
  duration = 1.2,
}: {
  target?: number;
  size?: number;
  strokeWidth?: number;
  duration?: number;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const isInView = useInView(ref, { once: true, amount: 0.8 });
  const [value, setValue] = useState(0);

  useEffect(() => {
    if (!isInView) return;
    const start = performance.now();
    let raf = 0;

    function tick(now: number) {
      const elapsed = (now - start) / 1000;
      const t = Math.min(elapsed / duration, 1);
      const eased = 1 - Math.pow(1 - t, 3);
      setValue(Math.round(eased * target));
      if (t < 1) raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [isInView, target, duration]);

  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;
  const offset = circumference * (1 - value / 100);

  return (
    <div ref={ref} className="relative inline-flex items-center justify-center">
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90">
        <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#e5e5e5" strokeWidth={strokeWidth} />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="#ff2d2d"
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
          style={{ transition: "stroke-dashoffset 80ms linear" }}
        />
      </svg>
      <span className="absolute text-2xl font-black tabular-nums">{value}%</span>
    </div>
  );
}
Notes

About this component

A circular progress indicator needs the ring fill and any numeric label inside it to move in lockstep, and the easiest way to break that is to animate them as two separate things timed to roughly match. This derives both from one piece of state: a requestAnimationFrame loop, the same cubic ease-out used in the odometer counter, drives a single percentage value, and the SVG ring's stroke-dashoffset is computed from that same number on every render, so there is no drift to slowly accumulate between the digit and the arc. The ring uses the stroke-dasharray and stroke-dashoffset technique rather than a CSS conic-gradient, because dasharray keeps a crisp, correctly rounded line cap at the ends of the arc that a conic-gradient mask cannot reproduce without extra clipping tricks. The whole SVG is rotated minus 90 degrees so the fill starts at 12 o'clock instead of the default 3 o'clock start, easy to forget when adapting the starting angle. It only counts up once per mount; there is no way to update it live from external data without remounting.

Curator’s note

The ring fill and the numeral read off the same piece of state, not two separate animations timed to roughly match — that's the difference between them staying perfectly synced and slowly drifting apart.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Trending
New

A button whose outline draws itself around the shape on hover, at any size.

buttonhoversvg
Featured
New

A marker that rides an SVG curve on scroll, banking through the bends.

scrollsvgoffset-path
NewPro

An SVG arc gauge with a needle and a readout that counts to its value.

gaugesvgmeter