Progress Ring

Circular progress that counts up when it scrolls into view.

0%

Storage

0%

Goal

0%

Uptime

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/progress-ring.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.