Components

Getting started

Blocks21

Components158

More

Parts of a whole as a ring or a half-ring gauge: point at a slice and the centre rolls to it, hide one and the rest slide round.

Used forSpend and billing pagesSales dashboardsBudgets and quotasSurvey and poll results

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/donut-chart.json

Templates

Need the whole page?

44 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 44 templates

Where it’s used

  • Spend and billing pages

    Cloud or ad spend by service or channel, with the total in the middle.

  • Sales dashboards

    Revenue by product, plan or region, switching between periods.

  • Budgets and quotas

    As a gauge with a max: money spent against a budget, storage used against a plan.

  • Survey and poll results

    How answers split, with a legend that can take an option out to compare the rest.

What it handles for you

Donuts usually come from a chart library that redraws on every change, or from SVG arc maths written by hand. This takes plain slices and handles the drawing, the gaps, the centre readout and the legend.

Instead of wiring upa chart librarySVG arc mathsa legend with togglesa centre labelnumber formatting
  1. Ring or gauge

    A full ring, or a half ring read from left to right, from the same slices.

  2. A centre that answers

    The total sits in the middle; point at a slice and it rolls to that slice’s value and share.

  3. A legend that filters

    Each row shows a slice’s figure and share, and clicking it takes the slice out while the rest close the gap.

  4. Changes, not redraws

    New numbers slide each slice to its new place and roll every figure.

  5. Against a max

    Give it a budget or a quota and the slices fill only their part, the rest of the track left empty.

  6. Your theme’s colours

    Slices take --chart-1 to --chart-5 in order, or any colour you pass.

Overview

A donut is the quickest way to show where something went, and most versions are a picture that redraws from scratch. In this one each slice draws round from where it starts the first time it’s seen, one after another, while the total in the centre rolls up from zero. Point at a slice or its row and it thickens as the others step back; the centre’s label morphs to its name and the figure rolls from the total to its value, with its share opening underneath. Click a row in the legend to take that slice out: the others slide round to close the gap and every share and the total roll to the new sums. New numbers, another week say, slide every slice to its new place. As a half-ring gauge with a max, the slices fill only their part of it, so a budget or a storage quota reads at a glance.

States

arrive
In view for the first time, each slice draws from its start (900ms, 90ms apart) and the total rolls up from zero.
point
Pointing at a slice or its row thickens it by 5px and dims the rest to 30%; the centre label morphs to its name, the figure rolls to its value and its share opens underneath. On touch a tap holds a slice and a tap elsewhere lets go.
toggle
Clicking a legend row hides its slice (the last one shown can’t be hidden): it shrinks into its neighbours as they slide round, and the shares and total roll to the new sums.
data
New values move every slice to its new length and place together (900ms) and roll every figure.
gauge
variant="gauge" lays the slices along a half ring from the left; with max, they fill only total ÷ max of it and the centre says “of” the max.

Usage

usage.tsx
import { DonutChart } from "@/components/donut-chart";<DonutChart  title="Spend by service"  data={[    { id: "compute", label: "Compute", value: 5240 },    { id: "storage", label: "Storage", value: 3180 },    { id: "bandwidth", label: "Bandwidth", value: 2260 },  ]}  format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}/>

Works with

A budget from your own numbers

Sum spending by category on the server and pass the budget as max; the gauge does the rest.

example.tsx
import { DonutChart } from "@/components/donut-chart";import { db } from "@/lib/db";export async function BudgetGauge({ month }: { month: string }) {  const rows = await db.spendByCategory({ month }); // [{ category: "Ingredients", total: 4820 }, …]  const budget = await db.budgetFor({ month }); // 15000  return (    <DonutChart      variant="gauge"      title="October budget"      centerLabel="Spent"      max={budget}      data={rows.map((r) => ({ id: r.category, label: r.category, value: r.total }))}      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}    />  );}

Props

PropTypeDescription
dataDonutSlice[]The slices in order: { id, label, value, color? }.
variant"donut" | "gauge"default "donut"A full ring, or a half ring read from left to right.
maxnumberWhat the whole ring stands for. Slices fill total ÷ max of it and the centre says “of” the max.
title / description / actionReactNodeThe header: a title, a line under it and something beside it, such as a period switch.
centerLabelstringdefault "Total"Above the figure while no slice is pointed at.
formatIntl.NumberFormatOptionsHow figures are written, e.g. { style: "currency", currency: "USD" }.
localesstring | string[]For number formatting.
thicknessnumberdefault 16Ring thickness in px at full size.
hiddenSlices / defaultHiddenSlicesstring[]Ids of the slices taken out; controlled or not.
onHiddenSlicesChange(ids: string[]) => voidCalled when the legend hides or shows a slice.
legendbooleandefault trueShow the legend, which also hides and shows slices.

Accessibility and motion

  • Grey in a new shadcn project? Its default theme sets --chart-1 to --chart-5 to greys. Set five colours, light and dark, as in Theming › Chart colours (/ui/theming#charts).
  • The legend is the accessible part: each row is a button (aria-pressed) whose label gives the slice’s name, value and share; the ring is decoration.
  • Focusing a legend row points at its slice, so keyboard users get the same centre readout.
  • Colours are --chart-1 to --chart-5 from your theme, in order; past five they repeat a step darker. Pass color on a slice to set your own.
  • Each slice is one circle’s stroke, so every change is a CSS transition of its length and start; no chart library and no animation frame loop.
  • It sizes to its own width: from about 450px the type steps up, and from about 510px the ring’s legend moves beside it (the gauge keeps it underneath).
  • Installs Number roll and Text morph. With reduced motion slices change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Text morph.

Source

components/donut-chart.tsx
"use client";import * as React from "react";import { NumberRoll } from "./number-roll";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * DONUT CHART: parts of a whole as a ring or a half-ring gauge * *   arrive    the first time it's on screen each slice draws *             round from where it starts, one after another, *             and the total rolls up from zero *   point     point at a slice (or its legend row) and it *             thickens while the others step back; the centre's *             label morphs to the slice's name and the figure *             rolls from the total to its value, with its share *             opening underneath *   toggle    a legend row hides or shows its slice: the others *             slide round to close the gap and every share and *             the total roll to the new sums *   data      new numbers (another month) slide every slice to *             its new place instead of redrawing *   max       given a max, the slices fill only their share of *             it and the rest of the track stays empty: budget *             spent, storage used * * Each slice is one circle's stroke: its length and where it * starts are stroke-dasharray and stroke-dashoffset, so a CSS * transition carries every change. The legend is the accessible * part (buttons that show and hide slices); the ring is drawn * for the eye only. * ───────────────────────────────────────────────────────── */export interface DonutSlice {  id: string;  label: string;  value: number;  /** Any CSS colour; slices without one take --chart-1 to --chart-5 in order. */  color?: string;}export interface DonutChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {  data: DonutSlice[];  /** A full ring, or a half-ring gauge read from left to right. */  variant?: "donut" | "gauge";  /** What a full ring stands for. With it the slices fill only their share and the rest of the track stays empty. */  max?: number;  title?: React.ReactNode;  description?: React.ReactNode;  /** Beside the title, e.g. a period switch. */  action?: React.ReactNode;  /** Above the figure in the centre while nothing is pointed at. */  centerLabel?: string;  format?: Intl.NumberFormatOptions;  locales?: string | string[];  /** Ring thickness in px, at the ring's full size. */  thickness?: number;  /** Ids of the slices taken out, controlled or not. */  hiddenSlices?: string[];  defaultHiddenSlices?: string[];  onHiddenSlicesChange?: (hidden: string[]) => void;  /** Show the legend (which also shows and hides slices). */  legend?: boolean;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const PALETTE = ["var(--chart-1, var(--primary))", "var(--chart-2, #e8743b)", "var(--chart-3, #1baf7a)", "var(--chart-4, #8b6fe8)", "var(--chart-5, #e0a21b)"];const NONE: string[] = [];const SIZE = 200;/** How much a pointed-at slice thickens, in px; the ring leaves room for it. */const GROW = 5;/** The gap between two slices, in px along the ring. */const GAP = 3.5;const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const subscribeNothing = () => () => {};/** Text in the visitor's locale waits for hydration, so the server's en-US render and the first client render match. */const useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);/** Past the fifth slice, colours repeat a step darker so neighbours never match. */const colorOf = (slice: DonutSlice, i: number) =>  slice.color ?? (i < PALETTE.length ? PALETTE[i] : `color-mix(in oklab, ${PALETTE[i % PALETTE.length]} 70%, var(--foreground))`);export function DonutChart({  data,  variant = "donut",  max,  title,  description,  action,  centerLabel = "Total",  format,  locales,  thickness = 16,  hiddenSlices,  defaultHiddenSlices = NONE,  onHiddenSlicesChange,  legend = true,  className = "",  ...props}: DonutChartProps) {  const reduced = useReducedMotion();  const gauge = variant === "gauge";  const [ownHidden, setOwnHidden] = React.useState(defaultHiddenSlices);  const off = hiddenSlices ?? ownHidden;  const [active, setActive] = React.useState<string | null>(null);  const [visible, setVisible] = React.useState(false);  const [settled, setSettled] = React.useState(false);  const hydrated = useHydrated();  const rootRef = React.useRef<HTMLDivElement>(null);  const ringRef = React.useRef<HTMLDivElement>(null);  React.useEffect(() => {    const el = rootRef.current;    if (!el) return;    const observer = new IntersectionObserver(      ([entry]) => {        if (entry.isIntersecting) {          setVisible(true);          observer.disconnect();        }      },      { rootMargin: "0px 0px -10% 0px" },    );    observer.observe(el);    return () => observer.disconnect();  }, []);  // Slices draw in one after another the first time only; after that every change moves them together.  React.useEffect(() => {    if (!visible) return;    const timer = window.setTimeout(() => setSettled(true), 900 + data.length * 90);    return () => window.clearTimeout(timer);  }, [visible, data.length]);  // A tap on a slice holds it; a tap anywhere else lets go.  React.useEffect(() => {    if (active === null) return;    const onDown = (e: PointerEvent) => {      if (!ringRef.current?.contains(e.target as Node)) setActive(null);    };    document.addEventListener("pointerdown", onDown);    return () => document.removeEventListener("pointerdown", onDown);  }, [active]);  const toggle = (id: string) => {    const shown = data.filter((s) => !off.includes(s.id)).length;    const next = off.includes(id) ? off.filter((x) => x !== id) : shown > 1 ? [...off, id] : off;    if (next === off) return;    if (hiddenSlices === undefined) setOwnHidden(next);    onHiddenSlicesChange?.(next);  };  // Geometry: one circle, the slices laid along its stroke.  const stroke = thickness;  const r = SIZE / 2 - (stroke + GROW) / 2 - 1;  const circumference = 2 * Math.PI * r;  const span = gauge ? circumference / 2 : circumference;  const height = gauge ? SIZE / 2 + (stroke + GROW) / 2 + 1 : SIZE;  const centre = SIZE / 2;  const slices = data.map((s, i) => ({ ...s, color: colorOf(s, i), shown: !off.includes(s.id), amount: Math.max(0, s.value) }));  const total = slices.reduce((sum, s) => sum + (s.shown ? s.amount : 0), 0);  const fill = max && max > 0 ? Math.min(1, total / max) : 1;  const shownCount = slices.filter((s) => s.shown && s.amount > 0).length;  const whole = !gauge && fill >= 1 && shownCount === 1;  const shares = slices.map((s) => (s.shown && total > 0 ? s.amount / total : 0));  const starts: number[] = [];  for (let i = 0, run = 0; i < shares.length; i++) {    starts.push(run);    run += shares[i] * fill * span;  }  const arcs = slices.map((s, i) => {    const share = shares[i];    const length = share * fill * span;    const start = starts[i];    // Round caps reach past each end by half the stroke: shorten the dash so the gaps stay GAP wide.    const drawn = whole && share > 0 ? circumference : Math.max(0, length - GAP - stroke);    const at = whole ? 0 : start + (GAP + stroke) / 2 + (length > 0 && length < GAP + stroke ? (length - GAP - stroke) / 2 : 0);    return { ...s, share, drawn: visible ? drawn : 0, at, on: visible && share > 0, delay: settled ? 0 : i * 90 };  });  const current = arcs.find((a) => a.id === active && a.shown) ?? null;  const fmt = (n: number) => new Intl.NumberFormat(hydrated ? locales : (locales ?? "en-US"), format).format(n);  const trackLength = gauge ? span - stroke : circumference;  const turn = gauge ? 180 : -90;  const transitionFor = (delay: number) =>    reduced ? "none" : `stroke-dasharray 900ms ${EASE} ${delay}ms, stroke-dashoffset 900ms ${EASE} ${delay}ms, stroke-width 320ms ${EASE}, opacity 300ms ${EASE}`;  const point = (id: string | null) => setActive(id);  const figureSize = gauge ? "text-[26px] @md:text-[30px]" : "text-[24px] @md:text-[28px]";  return (    <div ref={rootRef} className={`w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>      {/* Its natural width where the parent sizes to its content (a centred flex column), without forcing it on narrow screens. */}      <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 520px)" }} />      <div className="@container w-full">      <div className="p-4 @md:p-5">        {(title || description || action) && (          <div className="mb-4 flex items-start justify-between gap-3">            <div className="min-w-0">              {title && <p className="text-[13.5px] font-medium text-foreground @md:text-[14px]">{title}</p>}              {description && <p className="mt-0.5 text-[12px] text-muted-foreground @md:text-[12.5px]">{description}</p>}            </div>            {action && <div className="shrink-0">{action}</div>}          </div>        )}        <div className={`flex flex-col items-center gap-4 ${legend && !gauge ? "@lg:flex-row @lg:items-center @lg:gap-6" : ""}`}>          <div ref={ringRef} className={`relative shrink-0 ${gauge ? "w-[220px] @md:w-[236px]" : "w-[176px] @md:w-[196px]"}`} onPointerLeave={(e) => e.pointerType === "mouse" && point(null)}>            <svg viewBox={`0 0 ${SIZE} ${height}`} className="block w-full overflow-visible" aria-hidden="true">              <circle                cx={centre}                cy={centre}                r={r}                fill="none"                stroke="color-mix(in oklab, var(--foreground) 7%, transparent)"                strokeWidth={stroke}                strokeLinecap={gauge ? "round" : "butt"}                strokeDasharray={`${trackLength} ${circumference}`}                strokeDashoffset={gauge ? -stroke / 2 : 0}                transform={`rotate(${turn} ${centre} ${centre})`}              />              {arcs.map((a) => {                const dim = current !== null && current.id !== a.id;                const common = {                  cx: centre,                  cy: centre,                  r,                  fill: "none",                  strokeLinecap: "round" as const,                  strokeDasharray: `${a.drawn} ${circumference}`,                  strokeDashoffset: -a.at,                  transform: `rotate(${turn} ${centre} ${centre})`,                };                return (                  <g key={a.id}>                    <circle                      {...common}                      stroke={a.color}                      strokeWidth={current?.id === a.id ? stroke + GROW : stroke}                      style={{ opacity: a.on ? (dim ? 0.3 : 1) : 0, transition: transitionFor(a.delay) }}                    />                    {/* A wider, invisible copy takes the pointer, so thin slices are easy to hit. */}                    <circle                      {...common}                      stroke="transparent"                      strokeWidth={stroke + 14}                      pointerEvents={a.on ? "stroke" : "none"}                      className="cursor-pointer"                      onPointerEnter={(e) => e.pointerType === "mouse" && point(a.id)}                      onClick={() => point(active === a.id ? null : a.id)}                    />                  </g>                );              })}            </svg>            {/* The centre: the total, or the slice being pointed at. */}            <div className={`pointer-events-none absolute inset-x-0 flex flex-col items-center text-center ${gauge ? "bottom-0" : "inset-y-0 justify-center"}`}>              <span className="max-w-[70%] truncate text-[11.5px] text-muted-foreground @md:text-[12px]">                <TextMorph>{total === 0 ? "No data" : current ? current.label : centerLabel}</TextMorph>              </span>              <span className={`font-semibold leading-tight tracking-[-0.03em] text-foreground tabular-nums ${figureSize}`}>                <NumberRoll value={visible ? (current ? current.amount : total) : 0} format={format} locales={locales} duration={900} />              </span>              <span                className="grid text-[11.5px] text-muted-foreground tabular-nums transition-[grid-template-rows,opacity] duration-300 @md:text-[12px]"                style={{ gridTemplateRows: current || max ? "1fr" : "0fr", opacity: current || max ? 1 : 0, transitionTimingFunction: EASE }}              >                <span className="min-h-0 overflow-hidden">                  {current ? (                    <NumberRoll value={current.share} format={{ style: "percent" }} duration={700} />                  ) : max ? (                    <>                      of <NumberRoll value={max} format={format} locales={locales} duration={900} />                    </>                  ) : (                    " "                  )}                </span>              </span>            </div>          </div>          {legend && (            <ul className="w-full min-w-0 flex-1" aria-label="Slices">              {arcs.map((a) => (                <li key={a.id}>                  <button                    type="button"                    aria-pressed={a.shown}                    aria-label={`${a.label}: ${fmt(a.amount)}${a.shown ? `, ${Math.round(a.share * 100)}%` : ", hidden"}`}                    onClick={() => toggle(a.id)}                    onPointerEnter={(e) => e.pointerType === "mouse" && point(a.id)}                    onPointerLeave={(e) => e.pointerType === "mouse" && point(null)}                    onFocus={() => point(a.id)}                    onBlur={() => point(null)}                    className={`flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-[10px] px-2 text-left text-[12.5px] transition-[background-color,opacity] duration-200 hover:bg-accent @md:text-[13px] ${FOCUS}`}                    style={{ opacity: a.shown ? (current && current.id !== a.id ? 0.55 : 1) : 0.45 }}                  >                    <span                      aria-hidden="true"                      className="size-2.5 shrink-0 rounded-full transition-[background-color,box-shadow] duration-300"                      style={{ background: a.shown ? a.color : "transparent", boxShadow: `inset 0 0 0 1.5px ${a.color}` }}                    />                    <span className={`min-w-0 flex-1 truncate ${a.shown ? "text-foreground" : "text-muted-foreground line-through decoration-muted-foreground/60"}`}>{a.label}</span>                    <span className="tabular-nums text-foreground">                      <NumberRoll value={visible ? a.amount : 0} format={format} locales={locales} duration={900} />                    </span>                    <span className="w-10 text-right text-[11.5px] tabular-nums text-muted-foreground @md:text-[12px]">                      <NumberRoll value={visible ? a.share : 0} format={{ style: "percent" }} duration={900} />                    </span>                  </button>                </li>              ))}            </ul>          )}        </div>      </div>      </div>    </div>  );}

Registry item: /r/donut-chart.json