Circular Progress Ring
An SVG progress ring whose fill and centered percentage count up together on scroll into view.
- Category
- Components
- Added
- 2026-08-22
- Deps
- 1
- Updated
- 2026-09-01
Props
"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>
);
}
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.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Stroke Draw Button
componentsA button whose outline draws itself around the shape on hover, at any size.
Path Follow Scroll
animationsA marker that rides an SVG curve on scroll, banking through the bends.
Progress Arc Gauge
componentsAn SVG arc gauge with a needle and a readout that counts to its value.