{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-chart",
  "type": "registry:ui",
  "title": "Donut Chart",
  "description": "A gapped ring that sweeps in, lifts a segment and rolls its value.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/donut-chart.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(noop, () => Intl.NumberFormat().resolvedOptions().locale, () => \"en-US\");\n  return locale ?? detected;\n}\n\nexport type DonutDatum = {\n  label: string;\n  value: number;\n  /** Text-color class for this segment, e.g. \"text-danger\". Defaults to the foreground at a stepped opacity. */\n  className?: string;\n};\n\n// Largest first gets the most ink; the tail steps down.\nconst inks = [0.92, 0.64, 0.44, 0.3, 0.2, 0.13];\n\nfunction arc(c: number, r0: number, r1: number, a0: number, a1: number) {\n  const p = (r: number, a: number) => `${(c + r * Math.sin(a)).toFixed(2)} ${(c - r * Math.cos(a)).toFixed(2)}`;\n  const large = a1 - a0 > Math.PI ? 1 : 0;\n  return `M${p(r1, a0)}A${r1} ${r1} 0 ${large} 1 ${p(r1, a1)}L${p(r0, a1)}A${r0} ${r0} 0 ${large} 0 ${p(r0, a0)}Z`;\n}\n\n// Every slice gets at least a visible sweep; the rest is shared out by value.\nfunction layout(slices: DonutDatum[], sum: number, gap: number, minSweep: number, c: number, r0: number, r1: number) {\n  const floor = slices.map((d) => Math.max((d.value / (sum || 1)) * Math.PI * 2, minSweep));\n  const scale = (Math.PI * 2) / floor.reduce((s, v) => s + v, 0);\n  let a = 0;\n  return slices.map((d, i) => {\n    const sweep = floor[i] * scale;\n    const a0 = a + gap / 2;\n    const a1 = a + sweep - gap / 2;\n    a += sweep;\n    return { ...d, i, a0, a1, d: arc(c, r0, r1, a0, Math.max(a0 + 0.001, a1)) };\n  });\n}\n\nexport type DonutChartProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  data: DonutDatum[];\n  /** Names the chart for screen readers: \"Storage by file type\". */\n  label: string;\n  /** Diameter in pixels. */\n  size?: number;\n  /** Ring thickness in pixels. */\n  thickness?: number;\n  /** Intl options for every number, e.g. { style: \"currency\", currency: \"USD\" }. */\n  format?: Format;\n  /** Text after every number: \" GB\", \" seats\". */\n  suffix?: string;\n  locale?: string;\n  /** What the center says when nothing is highlighted. */\n  totalLabel?: string;\n  /** Override the center figure, e.g. used of a quota. Defaults to the sum. */\n  total?: number;\n  /** Segments past this many fold into one \"Other\" slice. */\n  maxSegments?: number;\n  legend?: \"right\" | \"bottom\" | \"none\";\n  loading?: boolean;\n  emptyLabel?: string;\n  /** Sweep the ring in the first time it scrolls into view. */\n  animated?: boolean;\n};\n\nexport function DonutChart({\n  data,\n  label,\n  size = 176,\n  thickness = 22,\n  format,\n  suffix,\n  locale: localeProp,\n  totalLabel = \"Total\",\n  total: totalProp,\n  maxSegments = 6,\n  legend = \"right\",\n  loading = false,\n  emptyLabel = \"No data\",\n  animated = true,\n  className,\n  ...rest\n}: DonutChartProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const box = useRef<HTMLDivElement>(null);\n  const inView = useInView(box, { once: true, amount: 0.5 });\n  const [hover, setHover] = useState<number | null>(null);\n  const [focus, setFocus] = useState<number | null>(null);\n  const [rover, setRover] = useState(0);\n  const maskId = useId();\n\n  // Sorted largest first, with a long tail folded into \"Other\" so no slice is a sliver nobody can hover.\n  const slices = useMemo(() => {\n    const clean = data.filter((d) => Number.isFinite(d.value) && d.value > 0).sort((a, b) => b.value - a.value);\n    if (clean.length <= maxSegments) return clean;\n    const head = clean.slice(0, maxSegments - 1);\n    const other = clean.slice(maxSegments - 1).reduce((s, d) => s + d.value, 0);\n    return [...head, { label: \"Other\", value: other }];\n  }, [data, maxSegments]);\n\n  const sum = slices.reduce((s, d) => s + d.value, 0);\n  const empty = !loading && sum === 0;\n  const nf = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n  const pf = useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", minimumFractionDigits: 1, maximumFractionDigits: 1 }), [locale]);\n  const fmt = (v: number) => `${nf.format(v)}${suffix ?? \"\"}`;\n\n  const c = size / 2;\n  const r1 = c - 6; // room for the hover lift\n  const r0 = r1 - thickness;\n  const rMid = (r0 + r1) / 2;\n  // A 2px surface gap between segments, measured at the middle of the ring.\n  const gap = slices.length > 1 ? 2 / rMid : 0;\n  const minSweep = gap + 0.035;\n  const segments = useMemo(() => layout(slices, sum, gap, minSweep, c, r0, r1), [slices, sum, gap, minSweep, c, r0, r1]);\n\n  const active = hover ?? focus;\n  const on = active != null ? segments[active] : null;\n  const shown = !animated || inView;\n  const inkOf = (i: number) => (slices[i]?.className ? 1 : inks[Math.min(i, inks.length - 1)]);\n\n  const onKey = (e: React.KeyboardEvent, i: number) => {\n    const n = segments.length;\n    const to =\n      e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? (i + 1) % n : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? (i - 1 + n) % n : e.key === \"Home\" ? 0 : e.key === \"End\" ? n - 1 : null;\n    if (e.key === \"Escape\") return setFocus(null);\n    if (to == null) return;\n    e.preventDefault();\n    e.currentTarget.parentElement?.querySelector<SVGElement>(`[data-index=\"${to}\"]`)?.focus();\n  };\n\n  // Touch has no hover: a tap pins a segment, a second tap or a tap elsewhere lets it go.\n  const tap = (i: number) => setHover((h) => (h === i ? null : i));\n  useEffect(() => {\n    if (hover == null) return;\n    const off = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && !box.current?.contains(e.target as Node)) setHover(null);\n    };\n    document.addEventListener(\"pointerdown\", off);\n    return () => document.removeEventListener(\"pointerdown\", off);\n  }, [hover]);\n\n  const center = on ?? { label: totalLabel, value: totalProp ?? sum };\n\n  return (\n    <div\n      ref={box}\n      data-slot=\"donut-chart\"\n      data-state={loading ? \"loading\" : empty ? \"empty\" : \"ready\"}\n      aria-busy={loading || undefined}\n      className={cn(\n        \"flex min-w-0 items-center gap-x-8 gap-y-5\",\n        legend === \"right\" ? \"flex-wrap justify-center\" : \"flex-col\",\n        className,\n      )}\n      {...rest}\n    >\n      <div className=\"relative shrink-0\" style={{ width: size, height: size }}>\n        <svg width={size} height={size} role=\"group\" aria-label={label} aria-roledescription=\"donut chart\" className=\"block overflow-visible\">\n          <defs>\n            <mask id={maskId} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={size} height={size}>\n              {/* The sweep: one thick stroke drawn clockwise from twelve o'clock reveals the ring. */}\n              <motion.circle\n                cx={c}\n                cy={c}\n                r={rMid}\n                fill=\"none\"\n                stroke=\"white\"\n                strokeWidth={thickness + 16}\n                transform={`rotate(-90 ${c} ${c})`}\n                initial={false}\n                animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }}\n                transition={reduce ? { pathLength: { duration: 0 }, opacity: { duration: 0.2 } } : { pathLength: { duration: 0.9, ease: ease.outQuart }, opacity: { duration: 0.1 } }}\n              />\n            </mask>\n          </defs>\n\n          {(empty || loading) && (\n            <circle cx={c} cy={c} r={rMid} fill=\"none\" strokeWidth={thickness} className={cn(\"stroke-fg/[0.07]\", loading && \"motion-safe:animate-pulse-soft\")} />\n          )}\n\n          {!loading && (\n            <g mask={`url(#${maskId})`}>\n              {segments.map((s) => {\n                const lifted = active === s.i;\n                const dim = active != null && !lifted;\n                return (\n                  <g\n                    key={s.label}\n                    data-index={s.i}\n                    tabIndex={s.i === Math.min(rover, segments.length - 1) ? 0 : -1}\n                    role=\"img\"\n                    aria-label={`${s.label}: ${fmt(s.value)}, ${pf.format(s.value / sum)}`}\n                    className=\"outline-none\"\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover(s.i)}\n                    onPointerLeave={(e) => e.pointerType === \"mouse\" && setHover(null)}\n                    onPointerDown={(e) => e.pointerType !== \"mouse\" && tap(s.i)}\n                    onFocus={(e) => {\n                      setRover(s.i);\n                      if ((e.currentTarget as Element).matches(\":focus-visible\")) setFocus(s.i);\n                    }}\n                    onBlur={() => setFocus(null)}\n                    onKeyDown={(e) => onKey(e, s.i)}\n                  >\n                    <path\n                      d={s.d}\n                      fill=\"currentColor\"\n                      fillOpacity={inkOf(s.i)}\n                      className={cn(\n                        s.className ?? \"text-fg\",\n                        \"transition-[transform,opacity] duration-200 ease-out-expo motion-reduce:transition-opacity\",\n                        focus === s.i && \"stroke-fg-3\",\n                      )}\n                      strokeWidth={focus === s.i ? 1 : 0}\n                      style={{\n                        transformBox: \"view-box\",\n                        transformOrigin: `${c}px ${c}px`,\n                        // The active segment grows outward by a few pixels; reduced motion keeps only the dimming.\n                        transform: lifted && !reduce ? `scale(${(r1 + 4) / r1})` : \"scale(1)\",\n                        opacity: dim ? 0.35 : 1,\n                      }}\n                    />\n                  </g>\n                );\n              })}\n            </g>\n          )}\n        </svg>\n\n        {/* The center answers for whatever is highlighted, and for the whole when nothing is. */}\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 grid place-items-center\">\n          <div className=\"flex flex-col items-center text-center\" style={{ maxWidth: r0 * 1.6 }}>\n            {loading ? (\n              <span className=\"h-5 w-16 rounded bg-fg/[0.07] motion-safe:animate-pulse-soft\" />\n            ) : empty ? (\n              <span className=\"text-[12.5px] text-fg-3\">{emptyLabel}</span>\n            ) : (\n              <>\n                <NumberFlow\n                  value={center.value}\n                  locales={locale}\n                  format={format}\n                  suffix={suffix}\n                  animated={!reduce}\n                  className=\"text-[20px] font-medium leading-tight tracking-[-0.02em] text-fg\"\n                />\n                <span className=\"relative grid h-4 w-full place-items-center overflow-hidden text-[11.5px] text-fg-3\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    <motion.span\n                      key={center.label}\n                      className=\"col-start-1 row-start-1 max-w-full truncate\"\n                      initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n                      transition={{ duration: 0.18, ease: ease.out }}\n                    >\n                      {center.label}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n              </>\n            )}\n          </div>\n        </div>\n        <span role=\"status\" className=\"sr-only\">\n          {on ? `${on.label}: ${fmt(on.value)}, ${pf.format(on.value / sum)}` : \"\"}\n        </span>\n      </div>\n\n      {legend !== \"none\" && !empty && (\n        <ul aria-label={`${label} legend`} className={cn(\"flex min-w-0 flex-col\", legend === \"right\" ? \"min-w-[200px] flex-1\" : \"w-full\")}>\n          {loading\n            ? Array.from({ length: 4 }, (_, i) => (\n                <li key={i} className=\"flex h-8 items-center gap-2.5 px-2\">\n                  <span className=\"size-2 rounded-[2px] bg-fg/[0.07]\" />\n                  <span className=\"h-2.5 flex-1 rounded bg-fg/[0.07] motion-safe:animate-pulse-soft\" style={{ maxWidth: 60 + ((i * 37) % 50) }} />\n                </li>\n              ))\n            : segments.map((s) => (\n                <li\n                  key={s.label}\n                  onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover(s.i)}\n                  onPointerLeave={(e) => e.pointerType === \"mouse\" && setHover(null)}\n                  onPointerDown={(e) => e.pointerType !== \"mouse\" && tap(s.i)}\n                  className={cn(\n                    \"flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[12.5px] transition-[background-color,opacity] duration-150\",\n                    active === s.i && \"bg-hover\",\n                    active != null && active !== s.i && \"opacity-55\",\n                  )}\n                >\n                  <span className={cn(\"size-2 shrink-0 rounded-[2px] bg-current\", s.className ?? \"text-fg\")} style={{ opacity: inkOf(s.i) }} />\n                  <span className=\"min-w-0 flex-1 truncate text-fg-2\">{s.label}</span>\n                  <span className=\"text-fg tabular\">{fmt(s.value)}</span>\n                  <span className=\"w-11 text-right text-[11.5px] text-fg-3 tabular\">{pf.format(s.value / sum)}</span>\n                </li>\n              ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/donut-chart"
}
