Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/* Theme-aware categorical palette (validated for light + dark and colour-vision deficiency). */
const PALETTE =
  "[--chart-1:#2a78d6] [--chart-2:#eb6834] [--chart-3:#1baf7a] [--chart-4:#eda100] [--chart-5:#e87ba4] [--chart-6:#008300] [--chart-7:#4a3aa7] [--chart-8:#e34948] " +
  "dark:[--chart-1:#3987e5] dark:[--chart-2:#d95926] dark:[--chart-3:#199e70] dark:[--chart-4:#c98500] dark:[--chart-5:#d55181] dark:[--chart-7:#9085e9] dark:[--chart-8:#e66767]";

export type DonutSlice = { id?: string; label: string; value: number; color?: string };

export type DonutChartProps = {
  data: DonutSlice[];
  title?: string;
  description?: string;
  /** Caption above the centre value when nothing is hovered. */
  centerLabel?: string;
  /** Max diameter in px (shrinks to fit the container). */
  size?: number;
  thickness?: number;
  /** Gap between segments, in degrees. */
  padAngle?: number;
  valueFormatter?: (v: number) => string;
  /** Formatter for the big centre value (defaults to valueFormatter). */
  centerFormatter?: (v: number) => string;
  showLegend?: boolean;
  /** Clicking a legend row hides that slice and re-balances the rest. */
  toggleable?: boolean;
  className?: string;
};

function useElementWidth<T extends HTMLElement>() {
  const ref = React.useRef<T>(null);
  const [width, setWidth] = React.useState(0);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, width] as const;
}

/** 0 → 1 over `ms`, eased; jumps to 1 when motion is reduced. */
function useDrawProgress(ms: number, skip: boolean) {
  const [p, setP] = React.useState(skip ? 1 : 0);
  React.useEffect(() => {
    if (skip) return;
    let raf = 0;
    const t0 = performance.now();
    const tick = (t: number) => {
      const k = Math.min(1, (t - t0) / ms);
      setP(1 - Math.pow(1 - k, 3));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [ms, skip]);
  return skip ? 1 : p;
}

/** Tweens an array of numbers toward `target` whenever it changes. */
function useTweened(target: number[], ms: number, skip: boolean) {
  const key = target.join(",");
  const [value, setValue] = React.useState(target);
  const current = React.useRef(target);
  React.useEffect(() => {
    const to = key.split(",").map(Number);
    const from = current.current;
    if (skip || from.length !== to.length) {
      current.current = to;
      return;
    }
    let raf = 0;
    const t0 = performance.now();
    const tick = (t: number) => {
      const k = Math.min(1, (t - t0) / ms);
      const e = 1 - Math.pow(1 - k, 3);
      const next = to.map((v, i) => from[i] + (v - from[i]) * e);
      current.current = next;
      setValue(next);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [key, ms, skip]);
  return skip || value.length !== target.length ? target : value;
}

function polar(cx: number, cy: number, r: number, a: number): [number, number] {
  return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}

function arcPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number) {
  const sweep = Math.min(a1 - a0, Math.PI * 2 - 1e-4);
  if (sweep <= 0) return "";
  const end = a0 + sweep;
  const large = sweep > Math.PI ? 1 : 0;
  const [x0, y0] = polar(cx, cy, r1, a0);
  const [x1, y1] = polar(cx, cy, r1, end);
  const [x2, y2] = polar(cx, cy, r0, end);
  const [x3, y3] = polar(cx, cy, r0, a0);
  return `M${x0},${y0}A${r1},${r1},0,${large},1,${x1},${y1}L${x2},${y2}A${r0},${r0},0,${large},0,${x3},${y3}Z`;
}

const fmt = new Intl.NumberFormat("en-US");

export function DonutChart({
  data,
  title,
  description,
  centerLabel = "Total",
  size = 220,
  thickness = 26,
  padAngle = 1.6,
  valueFormatter = (v) => fmt.format(v),
  centerFormatter,
  showLegend = true,
  toggleable = true,
  className,
}: DonutChartProps) {
  const reduce = !!useReducedMotion();
  const [wrapRef, width] = useElementWidth<HTMLDivElement>();
  const progress = useDrawProgress(1000, reduce);
  const [active, setActive] = React.useState<string | null>(null);
  const [hidden, setHidden] = React.useState<Set<string>>(new Set());

  const slices = data.map((d, i) => ({ ...d, key: d.id ?? d.label, color: d.color ?? `var(--chart-${(i % 8) + 1})` }));
  const shown = slices.filter((s) => !hidden.has(s.key));
  const total = shown.reduce((a, s) => a + Math.max(0, s.value), 0);

  const side = width >= 460;
  const diameter = Math.max(120, Math.min(size, side ? width * 0.48 : width));
  const cx = diameter / 2;
  const cy = diameter / 2;
  const explode = 6;
  const r1 = diameter / 2 - explode - 2;
  const r0 = Math.max(8, r1 - thickness);
  const pad = shown.length > 1 ? (padAngle * Math.PI) / 180 : 0;

  const targetFracs = slices.map((s) => (!hidden.has(s.key) && total > 0 ? Math.max(0, s.value) / total : 0));
  const fracs = useTweened(targetFracs, 450, reduce);

  const starts: number[] = [];
  for (let i = 0, acc = -Math.PI / 2; i < slices.length; i++) {
    starts.push(acc);
    acc += fracs[i] * Math.PI * 2 * progress;
  }
  const arcs = slices.map((s, i) => {
    const on = !hidden.has(s.key);
    const frac = targetFracs[i];
    const sweep = fracs[i] * Math.PI * 2 * progress;
    const a0 = starts[i];
    const mid = a0 + sweep / 2;
    const inset = sweep > pad ? pad / 2 : sweep / 2;
    return { ...s, on, frac, a0: a0 + inset, a1: a0 + sweep - inset, mid };
  });

  const activeSlice = arcs.find((a) => a.key === active && a.on) ?? null;
  const pctFmt = (f: number) => `${(f * 100).toFixed(f < 0.1 ? 1 : 0)}%`;

  const toggle = (key: string) =>
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else if (slices.length - next.size > 1) next.add(key);
      return next;
    });

  return (
    <figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
      {(title || description) && (
        <div className="mb-4">
          {title && <figcaption className="text-sm font-medium">{title}</figcaption>}
          {description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
        </div>
      )}
      <div ref={wrapRef} className={cn("flex w-full items-center gap-6", side ? "flex-row" : "flex-col")}>
        {width > 0 && (
          <div className="relative shrink-0" style={{ width: diameter, height: diameter }}>
            <svg width={diameter} height={diameter} role="img" aria-label={`${title ?? "Donut chart"}: ${shown.map((s) => `${s.label} ${pctFmt(total ? s.value / total : 0)}`).join(", ")}`}>
              <circle cx={cx} cy={cy} r={(r0 + r1) / 2} fill="none" className="stroke-muted" strokeWidth={r1 - r0} opacity={0.6} />
              {arcs.map((a) => {
                if (a.a1 <= a.a0) return null;
                const isActive = activeSlice?.key === a.key;
                const dx = isActive ? Math.cos(a.mid) * explode : 0;
                const dy = isActive ? Math.sin(a.mid) * explode : 0;
                return (
                  <motion.path
                    key={a.key}
                    d={arcPath(cx, cy, r0, r1, a.a0, a.a1)}
                    fill={a.color}
                    initial={false}
                    animate={{ x: dx, y: dy, opacity: activeSlice && !isActive ? 0.45 : 1 }}
                    transition={{ type: "spring", stiffness: 420, damping: 28 }}
                    onPointerEnter={() => setActive(a.key)}
                    onPointerLeave={() => setActive(null)}
                    className="cursor-pointer"
                  />
                );
              })}
            </svg>
            <div className="pointer-events-none absolute inset-0 grid place-items-center text-center">
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.div
                  key={activeSlice?.key ?? "__total"}
                  initial={{ opacity: 0, y: 4 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -4 }}
                  transition={{ duration: 0.18 }}
                  className="px-2"
                  style={{ maxWidth: r0 * 1.7 }}
                >
                  <p className="truncate text-xs text-muted-foreground">{activeSlice ? activeSlice.label : centerLabel}</p>
                  <p className={cn("font-semibold tracking-tight tabular-nums", diameter > 180 ? "text-2xl" : "text-lg")}>
                    {(centerFormatter ?? valueFormatter)(activeSlice ? activeSlice.value : total)}
                  </p>
                  {activeSlice && <p className="text-xs font-medium text-muted-foreground tabular-nums">{pctFmt(activeSlice.frac)}</p>}
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        )}

        {showLegend && (
          <ul className="w-full min-w-0 flex-1 space-y-0.5" aria-label="Legend">
            {arcs.map((a) => {
              const isActive = activeSlice?.key === a.key;
              return (
                <li key={a.key}>
                  <button
                    type="button"
                    aria-pressed={toggleable ? a.on : undefined}
                    onClick={toggleable ? () => toggle(a.key) : undefined}
                    onPointerEnter={() => setActive(a.key)}
                    onPointerLeave={() => setActive(null)}
                    onFocus={() => setActive(a.key)}
                    onBlur={() => setActive(null)}
                    aria-label={`${a.label}: ${valueFormatter(a.value)}${a.on ? `, ${pctFmt(a.frac)}` : ", hidden"}`}
                    className={cn(
                      "group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      isActive ? "bg-muted" : "hover:bg-muted/60",
                      !a.on && "text-muted-foreground",
                    )}
                  >
                    <span aria-hidden className={cn("size-2.5 shrink-0 rounded-[3px] transition-opacity", !a.on && "opacity-25")} style={{ background: a.color }} />
                    <span className={cn("min-w-0 flex-1 truncate", !a.on && "line-through")}>{a.label}</span>
                    <span className="font-medium tabular-nums">{valueFormatter(a.value)}</span>
                    <span className="w-12 text-right text-xs text-muted-foreground tabular-nums">{a.on ? pctFmt(a.frac) : "—"}</span>
                  </button>
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </figure>
  );
}

More in Data Display

View all →