# Donut Chart

> Segments draw themselves in on scroll; hover, tap or tab to one to lift it and read its value and share in the middle.

Category: Charts · Free · Updated 2026-10-21 · Live demo: https://motionkitui.com/components/donut-chart

## When to use it

Dashboards, reports and case studies: traffic sources, budget splits, survey answers or any share of a whole.

## How it works

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.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/donut-chart.json
```

Or copy `components/motionkit/donut-chart.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| 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. |

## Accessibility

- **Keyboard: Pass.** The legend is a list of buttons: tabbing to one highlights its segment and updates the centre.
- **Screen readers: Pass.** Each legend button reads its label, value and percentage, e.g. "Direct: 4,200, 42%". The drawing itself is hidden.
- **Reduced motion: Pass.** With reduced motion on, the segments appear fully drawn and the centre swaps without sliding.
- **Colour contrast: Partial.** Legend text measures 14.7:1. Segment colours are told apart by the legend and labels, but very similar custom colours may be hard to separate.

## Source

```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>
  );
}
```
