Segments draw themselves in on scroll; hover, tap or tab to one to lift it and read its value and share in the middle.
Visitors
10,000
Customize
Usage
import { DonutChart } from "@/components/motionkit/donut-chart";
<DonutChart
title="Visitors by channel"
data={[
{ label: "Direct", value: 4200 },
{ label: "Search", value: 3100 },
{ label: "Social", value: 1700 },
{ label: "Referral", value: 1000 },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Segment draw
Scrolls into view
Highlight
Hover or focus a segment
Dashboards, reports and case studies: traffic sources, budget splits, survey answers or any share of a whole.
Every segment is the same SVG circle with a stroke-dasharray as long as its share and a dash offset where the earlier segments end. When the chart scrolls into view, Motion grows each dash from zero in turn. Hovering a segment or focusing its legend entry thickens it on a spring, fades the rest and swaps the centre to that segment's value and percentage. The legend is real buttons, so it's the keyboard way in.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/donut-chart.jsonnpm install motionSave as components/motionkit/donut-chart.tsx.
"use client";
import { useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
export type DonutSlice = { label: string; value: number; color?: string };
type DonutChartProps = {
data: DonutSlice[];
/** Heading in the middle when nothing is highlighted, e.g. "Total". */
totalLabel?: string;
/** Formats every number, e.g. (v) => `£${v.toLocaleString()}`. */
format?: (value: number) => string;
/** Diameter in pixels. */
size?: number;
/** Ring thickness in pixels. */
thickness?: number;
/** Accessible name for the chart, e.g. "Revenue by channel". */
title?: string;
className?: string;
};
// brand-led defaults; any slice can set its own colour
const palette = ["#5FAEB4", "#0E7C86", "#A56D5B", "#7D9E9D", "#E4DCCF", "#3A4A49"];
/** A donut that draws its segments in on scroll; hover, tap or tab to a segment to lift it and read its share. */
export function DonutChart({
data,
totalLabel = "Total",
format = (v) => v.toLocaleString(),
size = 220,
thickness = 22,
title = "Chart",
className = "",
}: DonutChartProps) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.5 });
const reduceMotion = useReducedMotion();
const [active, setActive] = useState<number | null>(null);
const total = data.reduce((sum, slice) => sum + slice.value, 0) || 1;
const radius = (size - thickness) / 2 - 6;
const circumference = 2 * Math.PI * radius;
// a hairline gap between segments, unless a segment is too small to spare it
const gap = data.length > 1 ? 3 : 0;
const slices = data.map((slice, index) => {
const share = slice.value / total;
// each segment starts where the ones before it end
const start = (data.slice(0, index).reduce((sum, earlier) => sum + earlier.value, 0) / total) * circumference;
const length = Math.max(share * circumference - gap, 0.5);
return { ...slice, index, share, length, start, color: slice.color ?? palette[index % palette.length] };
});
const focused = active === null ? null : slices[active];
return (
<div ref={ref} className={`flex flex-wrap items-center gap-8 text-[#F6F1E8] ${className}`}>
<figure className="relative shrink-0" style={{ width: size, height: size }}>
<svg viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden>
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="rgb(255 255 255 / 0.06)" strokeWidth={thickness} />
{slices.map((slice) => (
<motion.circle
key={slice.label}
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={slice.color}
strokeLinecap="butt"
strokeDashoffset={-slice.start}
onPointerEnter={() => setActive(slice.index)}
onPointerLeave={() => setActive(null)}
initial={{ strokeDasharray: `0 ${circumference}`, strokeWidth: thickness }}
animate={{
strokeDasharray: inView || reduceMotion ? `${slice.length} ${circumference}` : `0 ${circumference}`,
// the highlighted segment thickens, the rest step back
strokeWidth: active === slice.index ? thickness + 8 : thickness,
opacity: active === null || active === slice.index ? 1 : 0.35,
}}
transition={{
strokeDasharray: reduceMotion ? { duration: 0 } : { duration: 0.9, delay: 0.1 + slice.index * 0.12, ease: [0.65, 0, 0.35, 1] },
strokeWidth: { type: "spring", stiffness: 300, damping: 22 },
opacity: { duration: 0.2 },
}}
className="cursor-pointer"
/>
))}
</svg>
{/* the centre reads the total, or the highlighted segment's value and share */}
<figcaption className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={focused?.label ?? "total"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{ duration: 0.2 }}
>
<p className="text-xs text-[#94A9A7]">{focused?.label ?? totalLabel}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{format(focused?.value ?? total)}</p>
{focused && <p className="mt-0.5 text-xs tabular-nums" style={{ color: focused.color }}>{Math.round(focused.share * 100)}%</p>}
</motion.div>
</AnimatePresence>
</figcaption>
</figure>
{/* the legend doubles as the keyboard way in: each entry highlights its segment */}
<ul aria-label={title} className="min-w-40 space-y-1.5 text-sm">
{slices.map((slice) => (
<li key={slice.label}>
<button
type="button"
onPointerEnter={() => setActive(slice.index)}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(slice.index)}
onBlur={() => setActive(null)}
aria-label={`${slice.label}: ${format(slice.value)}, ${Math.round(slice.share * 100)}%`}
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-[#5FAEB4] ${
active === slice.index ? "bg-white/[0.06]" : "hover:bg-white/[0.03]"
}`}
>
<span aria-hidden className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: slice.color }} />
<span className="flex-1 text-[#DCE3E2]">{slice.label}</span>
<span aria-hidden className="tabular-nums text-[#94A9A7]">{Math.round(slice.share * 100)}%</span>
</button>
</li>
))}
</ul>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| data | DonutSlice[] | — | Each slice's label, value and optional colour. |
| totalLabel | string | "Total" | Heading in the middle when nothing is highlighted. |
| format | (value: number) => string | — | Formats every number. |
| size | number | 220 | Diameter in pixels. |
| thickness | number | 22 | Ring thickness in pixels. |
| title | string | "Chart" | Accessible name for the legend. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/donut-chart.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the segments appear fully drawn and the centre swaps without sliding.
KPI Card
A headline number that counts up beside a sparkline that draws itself, with a change badge and hover readout.
Odometer
Every digit rolls on its own wheel to the new value, like a mileage counter, rather than counting up.
Connection Globe
A dotted 3D globe you can spin by dragging, with markers pulsing at real coordinates and light travelling along arcs between them.