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