Donut Chart

Interactive donut with legend — hover a slice to focus it.

100

Total visits (k)

  • Direct42k
  • Search28k
  • Social18k
  • Email12k

Installation

  1. Install the dependencies

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

    components/ui/donut-chart.tsx
    "use client";
    
    import { motion } from "motion/react";
    import { useState } from "react";
    
    type Slice = { label: string; value: number; color: string };
    
    const defaultSlices: Slice[] = [
      { label: "Direct", value: 42, color: "#2563eb" },
      { label: "Search", value: 28, color: "#7c3aed" },
      { label: "Social", value: 18, color: "#ec4899" },
      { label: "Email", value: 12, color: "#f59e0b" },
    ];
    
    export function DonutChart({ slices = defaultSlices, size = 180, stroke = 26 }: { slices?: Slice[]; size?: number; stroke?: number }) {
      const [active, setActive] = useState<number | null>(null);
      const total = slices.reduce((s, x) => s + x.value, 0);
      const r = (size - stroke) / 2;
      const c = 2 * Math.PI * r;
      let acc = 0;
      const shown = active === null ? null : slices[active];
    
      return (
        <div className="flex flex-wrap items-center gap-8">
          <div className="relative" style={{ width: size, height: size }}>
            <svg width={size} height={size} className="-rotate-90" onMouseLeave={() => setActive(null)}>
              {slices.map((s, i) => {
                const len = (s.value / total) * c;
                const offset = -acc;
                acc += len;
                return (
                  <motion.circle
                    key={s.label}
                    cx={size / 2}
                    cy={size / 2}
                    r={r}
                    fill="none"
                    stroke={s.color}
                    strokeWidth={active === i ? stroke + 6 : stroke}
                    strokeDashoffset={offset}
                    initial={{ strokeDasharray: `0 ${c}` }}
                    whileInView={{ strokeDasharray: `${Math.max(len - 2, 0)} ${c}` }}
                    viewport={{ once: true }}
                    transition={{ duration: 0.9, delay: i * 0.12 }}
                    onMouseEnter={() => setActive(i)}
                    className="cursor-pointer transition-[stroke-width] duration-200"
                    opacity={active === null || active === i ? 1 : 0.35}
                  />
                );
              })}
            </svg>
            <div className="pointer-events-none absolute inset-0 grid place-items-center text-center">
              <div>
                <p className="text-2xl font-semibold text-zinc-900">{shown ? `${Math.round((shown.value / total) * 100)}%` : total}</p>
                <p className="text-xs text-zinc-500">{shown ? shown.label : "Total visits (k)"}</p>
              </div>
            </div>
          </div>
          <ul className="space-y-2">
            {slices.map((s, i) => (
              <li key={s.label} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} className="flex cursor-default items-center gap-2 text-sm text-zinc-600">
                <span className="size-2.5 rounded-full" style={{ background: s.color }} />
                <span className={active === i ? "font-medium text-zinc-900" : ""}>{s.label}</span>
                <span className="ml-auto pl-6 tabular-nums text-zinc-400">{s.value}k</span>
              </li>
            ))}
          </ul>
        </div>
      );
    }
    
    export default function Demo() {
      return (
        <div className="rounded-2xl border border-zinc-200 bg-white p-6">
          <DonutChart />
        </div>
      );
    }
    
  3. Use it

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