Circular Progress

Animated SVG progress ring with gradient stroke and count-up number

animationprogressringsvgdata-viz
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useEffect, useId } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";

interface CircularProgressProps {
  value?: number;
  size?: number;
  strokeWidth?: number;
  colors?: [string, string];
  showValue?: boolean;
  className?: string;
}

function CountUp({ value }: { value: import("framer-motion").MotionValue<number> }) {
  const rounded = useTransform(value, (v) => Math.round(v));
  return <motion.span>{rounded}</motion.span>;
}

export default function CircularProgress({
  value = 0,
  size = 120,
  strokeWidth = 8,
  colors = ["#6366f1", "#ec4899"],
  showValue = true,
  className = "",
}: CircularProgressProps) {
  const gradientId = useId();
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;

  const motionValue = useMotionValue(0);
  const spring = useSpring(motionValue, { damping: 20, stiffness: 80 });
  const dashOffset = useTransform(spring, (v) => circumference * (1 - v / 100));

  useEffect(() => {
    motionValue.set(value);
  }, [value, motionValue]);

  return (
    <div
      className={`relative inline-flex items-center justify-center ${className}`}
      style={{ width: size, height: size }}
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        className="-rotate-90"
      >
        <defs>
          <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
            <stop offset="0%" stopColor={colors[0]} />
            <stop offset="100%" stopColor={colors[1]} />
          </linearGradient>
        </defs>

        {/* Background track */}
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="rgba(255,255,255,0.08)"
          strokeWidth={strokeWidth}
        />

        {/* Progress arc */}
        <motion.circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke={`url(#${gradientId})`}
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeDasharray={circumference}
          style={{ strokeDashoffset: dashOffset }}
        />
      </svg>

      {showValue && (
        <div className="absolute inset-0 flex items-center justify-center">
          <span className="text-2xl font-bold text-white">
            <CountUp value={spring} />
            <span className="text-sm text-gray-400">%</span>
          </span>
        </div>
      )}
    </div>
  );
}