← Back
Canvas

Donut Chart

SVG donut chart with animated stroke-dasharray segments and interactive legend.

100%

Total

Design

35%

Development

40%

Testing

15%

Deploy

10%

SVG stroke-dasharray · animated segments · hover highlight

DonutChart.tsx122 lines
"use client";

import { useEffect, useState } from "react";

const DATA = [
  { label: "Design",      value: 35, color: "#8b5cf6" },
  { label: "Development", value: 40, color: "#3b82f6" },
  { label: "Testing",     value: 15, color: "#10b981" },
  { label: "Deploy",      value: 10, color: "#f59e0b" },
];

const R = 42;
const CX = 60;
const CY = 60;
const STROKE_W = 14;
const C = 2 * Math.PI * R;

const DATA_TOTAL = DATA.reduce((s, d) => s + d.value, 0);
const DATA_STARTS = (() => {
  let a = 0;
  return DATA.map((seg) => {
    const start = a;
    a += (seg.value / DATA_TOTAL) * 360;
    return start;
  });
})();

export default function DonutChart() {
  const [animated, setAnimated] = useState(false);
  const [hovered, setHovered] = useState<number | null>(null);
  const [key, setKey] = useState(0);

  useEffect(() => {
    const t = setTimeout(() => setAnimated(true), 200);
    return () => clearTimeout(t);
  }, [key]);

  return (
    <div className="flex flex-col items-center gap-8 py-8 w-full">
      <div className="flex flex-col sm:flex-row items-center gap-10">
        {/* chart */}
        <div className="relative" style={{ width: 160, height: 160 }}>
          <svg width={120} height={120} viewBox="0 0 120 120" className="w-40 h-40 -rotate-90">
            {/* track */}
            <circle cx={CX} cy={CY} r={R} fill="none" stroke="currentColor"
              className="text-zinc-100 dark:text-zinc-800" strokeWidth={STROKE_W} />

            {DATA.map((seg, i) => {
              const frac = seg.value / DATA_TOTAL;
              const arcLen = frac * C;
              const start = DATA_STARTS[i];

              const dashLen = animated ? arcLen : 0;
              const isHovered = hovered === i;

              return (
                <circle
                  key={seg.label}
                  cx={CX} cy={CY} r={R}
                  fill="none"
                  stroke={seg.color}
                  strokeWidth={isHovered ? STROKE_W + 3 : STROKE_W}
                  strokeDasharray={`${dashLen} ${C - dashLen}`}
                  strokeDashoffset={0}
                  strokeLinecap="round"
                  transform={`rotate(${start} ${CX} ${CY})`}
                  style={{ transition: "stroke-dasharray 0.9s cubic-bezier(0.16,1,0.3,1), stroke-width 0.2s ease", transitionDelay: `${i * 150}ms` }}
                  onMouseEnter={() => setHovered(i)}
                  onMouseLeave={() => setHovered(null)}
                  className="cursor-pointer"
                />
              );
            })}
          </svg>

          {/* center label */}
          <div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
            {hovered !== null ? (
              <>
                <p className="text-xl font-black text-zinc-900 dark:text-zinc-50">{DATA[hovered].value}%</p>
                <p className="text-[10px] font-mono text-zinc-400">{DATA[hovered].label}</p>
              </>
            ) : (
              <>
                <p className="text-2xl font-black text-zinc-900 dark:text-zinc-50">{DATA_TOTAL}%</p>
                <p className="text-[10px] font-mono text-zinc-400">Total</p>
              </>
            )}
          </div>
        </div>

        {/* legend */}
        <div className="flex flex-col gap-3">
          {DATA.map((seg, i) => (
            <div
              key={seg.label}
              className={`flex items-center gap-3 cursor-pointer transition-opacity duration-200 ${hovered !== null && hovered !== i ? "opacity-40" : "opacity-100"}`}
              onMouseEnter={() => setHovered(i)}
              onMouseLeave={() => setHovered(null)}
            >
              <div className="w-3 h-3 rounded-full shrink-0" style={{ background: seg.color }} />
              <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300 w-24">{seg.label}</p>
              <p className="text-sm font-bold text-zinc-900 dark:text-zinc-50 tabular-nums">{seg.value}%</p>
            </div>
          ))}
        </div>
      </div>

      <button
        onClick={() => { setAnimated(false); setTimeout(() => { setKey(k => k + 1); }, 50); }}
        className="text-xs font-mono text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 transition-colors"
      >
        ↺ replay
      </button>

      <p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600">
        SVG stroke-dasharray · animated segments · hover highlight
      </p>
    </div>
  );
}

More in Canvas