Donut Chart
Interactive donut with legend — hover a slice to focus it.
100
Total visits (k)
- Direct42k
- Search28k
- Social18k
- Email12k
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/donut-chart.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.