Progress Ring
Circular progress that counts up when it scrolls into view.
0%
Storage
0%
Goal
0%
Uptime
components/ui/progress-ring.tsx
"use client";
import { animate, motion, useInView, useMotionValue, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
type ProgressRingProps = {
/** 0 - 100 */
value: number;
size?: number;
stroke?: number;
color?: string;
label?: string;
};
export function ProgressRing({ value, size = 140, stroke = 12, color = "#2563eb", label }: ProgressRingProps) {
const ref = useRef<SVGSVGElement>(null);
const inView = useInView(ref, { once: true });
const progress = useMotionValue(0);
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const offset = useTransform(progress, (v) => c - (v / 100) * c);
const text = useTransform(progress, (v) => `${Math.round(v)}%`);
useEffect(() => {
if (!inView) return;
const a = animate(progress, value, { duration: 1.4, ease: [0.22, 1, 0.36, 1] });
return () => a.stop();
}, [inView, value, progress]);
return (
<div className="relative inline-grid place-items-center" style={{ width: size, height: size }} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100} aria-label={label}>
<svg ref={ref} width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#f4f4f5" strokeWidth={stroke} />
<motion.circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round" strokeDasharray={c} style={{ strokeDashoffset: offset }} />
</svg>
<div className="absolute text-center">
<motion.p className="text-2xl font-semibold tabular-nums text-zinc-900">{text}</motion.p>
{label && <p className="text-xs text-zinc-500">{label}</p>}
</div>
</div>
);
}
export default function Demo() {
return (
<div className="flex flex-wrap justify-center gap-8">
<ProgressRing value={72} label="Storage" />
<ProgressRing value={45} label="Goal" color="#16a34a" />
<ProgressRing value={91} label="Uptime" color="#7c3aed" />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/progress-ring.tsxcomponents/ui/progress-ring.tsx"use client"; import { animate, motion, useInView, useMotionValue, useTransform } from "motion/react"; import { useEffect, useRef } from "react"; type ProgressRingProps = { /** 0 - 100 */ value: number; size?: number; stroke?: number; color?: string; label?: string; }; export function ProgressRing({ value, size = 140, stroke = 12, color = "#2563eb", label }: ProgressRingProps) { const ref = useRef<SVGSVGElement>(null); const inView = useInView(ref, { once: true }); const progress = useMotionValue(0); const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const offset = useTransform(progress, (v) => c - (v / 100) * c); const text = useTransform(progress, (v) => `${Math.round(v)}%`); useEffect(() => { if (!inView) return; const a = animate(progress, value, { duration: 1.4, ease: [0.22, 1, 0.36, 1] }); return () => a.stop(); }, [inView, value, progress]); return ( <div className="relative inline-grid place-items-center" style={{ width: size, height: size }} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100} aria-label={label}> <svg ref={ref} width={size} height={size} className="-rotate-90"> <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#f4f4f5" strokeWidth={stroke} /> <motion.circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round" strokeDasharray={c} style={{ strokeDashoffset: offset }} /> </svg> <div className="absolute text-center"> <motion.p className="text-2xl font-semibold tabular-nums text-zinc-900">{text}</motion.p> {label && <p className="text-xs text-zinc-500">{label}</p>} </div> </div> ); } export default function Demo() { return ( <div className="flex flex-wrap justify-center gap-8"> <ProgressRing value={72} label="Storage" /> <ProgressRing value={45} label="Goal" color="#16a34a" /> <ProgressRing value={91} label="Uptime" color="#7c3aed" /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.