Charts

Donut Chart

An SVG donut whose segments sweep in sequentially while the center value counts up, with hover-linked legend and segment expansion.

Install

npx shadcn@latest add @paragon/donut-chart

Also installs: number-ticker

donut-chart.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import { cn } from "@/lib/utils";

const SERIES_COLORS = [
  "oklch(0.585 0.17 260)",
  "oklch(0.68 0.13 165)",
  "oklch(0.76 0.13 85)",
  "oklch(0.62 0.18 305)",
  "oklch(0.66 0.16 25)",
];

export interface DonutChartDatum {
  label: string;
  value: number;
  color?: string;
}

export interface DonutChartProps extends React.ComponentProps<"div"> {
  data: DonutChartDatum[];
  size?: number;
  /** Ring thickness in px. */
  thickness?: number;
  /** Gap between segments, in degrees. */
  gap?: number;
  /** Caption under the center value. */
  centerLabel?: string;
  /** Center value. Defaults to the sum of all segments. */
  centerValue?: number;
  /** Hide the center total readout. */
  showCenter?: boolean;
  /** Intl.NumberFormat options for the center value and legend. */
  formatOptions?: Intl.NumberFormatOptions;
  showLegend?: boolean;
  /** Accessible description of the chart. */
  label?: string;
  /** Renders the final state immediately, no sweep-in. */
  static?: boolean;
}

/** Polar → cartesian. Angle in degrees, 0° at 12 o'clock, clockwise. */
function polar(cx: number, cy: number, r: number, deg: number) {
  const rad = ((deg - 90) * Math.PI) / 180;
  return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)] as const;
}

/** An arc path from `startDeg` to `endDeg` at radius `r`, drawn clockwise. */
function arcPath(
  cx: number,
  cy: number,
  r: number,
  startDeg: number,
  endDeg: number,
) {
  // A single segment spanning the whole ring degenerates (start ≈ end) —
  // draw it as two half-circle arcs so it renders as a closed ring.
  if (endDeg - startDeg >= 359.9) {
    const [x1, y1] = polar(cx, cy, r, startDeg);
    const [xm, ym] = polar(cx, cy, r, startDeg + 180);
    return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 1 1 ${xm.toFixed(3)} ${ym.toFixed(3)} A ${r} ${r} 0 1 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
  }
  const [x1, y1] = polar(cx, cy, r, startDeg);
  const [x2, y2] = polar(cx, cy, r, endDeg);
  const large = endDeg - startDeg > 180 ? 1 : 0;
  return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
}

/**
 * A hand-built SVG donut with parametrically computed arc segments. Every
 * segment start/end angle is derived from the running cumulative fraction —
 * no gaps or overlaps. Segments sweep in from zero once on first view via a
 * single masked reveal while the center value counts up.
 *
 * Interactive: hover or focus a segment (or its legend row) to lift it 2px
 * outward and dim the rest, with a cursor-anchored tooltip showing the value
 * and share; click a legend row to toggle its segment out of the total.
 * Reduced motion renders the final state immediately.
 */
export function DonutChart({
  data,
  size = 200,
  thickness = 22,
  gap = 2,
  centerLabel,
  centerValue,
  showCenter = true,
  formatOptions,
  showLegend = true,
  label,
  static: isStatic = false,
  className,
  ...props
}: DonutChartProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -48px 0px",
  });
  const [active, setActive] = React.useState<number | null>(null);
  const [hidden, setHidden] = React.useState<Set<number>>(new Set());
  const [cursor, setCursor] = React.useState<{ x: number; y: number } | null>(
    null,
  );
  const maskId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const c = size / 2;
  const r = (size - thickness) / 2 - 3;

  const format = React.useMemo(
    () => new Intl.NumberFormat("en-US", formatOptions),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [JSON.stringify(formatOptions)],
  );
  const percentFormat = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "percent",
        maximumFractionDigits: 1,
      }),
    [],
  );

  const colorFor = (i: number) =>
    data[i]?.color ?? SERIES_COLORS[i % SERIES_COLORS.length];

  const visibleTotal = Math.max(
    data.reduce((sum, d, i) => (hidden.has(i) ? sum : sum + d.value), 0),
    1e-9,
  );
  const isEmpty = data.length === 0;
  const allHidden =
    !isEmpty && data.every((_, i) => hidden.has(i));

  // Parametric segment angles from the running cumulative fraction.
  const gapDeg = data.length > 1 ? gap : 0;
  let cursorDeg = 0;
  const segments = data.map((d, i) => {
    const isHidden = hidden.has(i);
    const sweep = isHidden ? 0 : (d.value / visibleTotal) * 360;
    const startDeg = cursorDeg + gapDeg / 2;
    const endDeg = cursorDeg + sweep - gapDeg / 2;
    cursorDeg += sweep;
    const midDeg = (startDeg + endDeg) / 2;
    const [ox, oy] = polar(0, 0, 1, midDeg); // outward unit vector
    return {
      ...d,
      index: i,
      startDeg,
      endDeg,
      midDeg,
      sweep,
      isHidden,
      offset: [ox, oy] as const,
    };
  });

  const activeSeg = active !== null ? segments[active] : null;

  const displayTotal = centerValue ?? visibleTotal;

  const onMove = (e: React.PointerEvent) => {
    const rect = containerRef.current
      ?.querySelector("[data-donut-svg-wrap]")
      ?.getBoundingClientRect();
    if (!rect) return;
    setCursor({ x: e.clientX - rect.left, y: e.clientY - rect.top });
  };

  return (
    <div
      ref={containerRef}
      data-slot="donut-chart"
      className={cn("flex flex-wrap items-center gap-6", className)}
      {...props}
    >
      <div
        data-donut-svg-wrap
        className="relative shrink-0"
        style={{ width: size, height: size }}
        onPointerMove={onMove}
      >
        <svg
          role="img"
          aria-label={
            label ??
            `Donut chart: ${data
              .map((d) => `${d.label} ${format.format(d.value)}`)
              .join(", ")}`
          }
          width={size}
          height={size}
          viewBox={`0 0 ${size} ${size}`}
          className="block"
          onPointerLeave={() => {
            setActive(null);
            setCursor(null);
          }}
        >
          <defs>
            <mask id={maskId}>
              {/* One sweeping mask reveals every segment in sequence. */}
              <circle
                cx={c}
                cy={c}
                r={r}
                fill="none"
                stroke="#fff"
                strokeWidth={thickness + 10}
                pathLength={1}
                strokeDasharray={1}
                transform={`rotate(-90 ${c} ${c})`}
                style={{
                  strokeDashoffset: drawn ? 0 : 1,
                  transition: animate
                    ? "stroke-dashoffset 650ms var(--ease-out)"
                    : undefined,
                }}
              />
            </mask>
          </defs>

          {/* Faint track so an empty / all-hidden chart still reads as a ring. */}
          <circle
            cx={c}
            cy={c}
            r={r}
            fill="none"
            stroke="currentColor"
            strokeOpacity={0.08}
            strokeWidth={thickness}
          />

          <g mask={`url(#${maskId})`}>
            {segments.map((segment) => {
              if (segment.isHidden || segment.sweep <= 0) return null;
              const isActive = active === segment.index;
              const dimmed = active !== null && !isActive;
              const dx = segment.offset[0] * 2;
              const dy = segment.offset[1] * 2;
              const lift = isActive && !reducedMotion;
              return (
                <path
                  key={segment.index}
                  role="button"
                  tabIndex={0}
                  aria-label={`${segment.label}: ${format.format(
                    segment.value,
                  )}, ${percentFormat.format(segment.value / visibleTotal)}`}
                  d={arcPath(c, c, r, segment.startDeg, segment.endDeg)}
                  fill="none"
                  stroke={colorFor(segment.index)}
                  strokeWidth={thickness}
                  strokeLinecap={segment.sweep >= 359.5 ? "butt" : "round"}
                  vectorEffect="non-scaling-stroke"
                  onPointerEnter={() => setActive(segment.index)}
                  onFocus={() => setActive(segment.index)}
                  onBlur={() => setActive(null)}
                  className="cursor-pointer outline-none focus-visible:stroke-foreground"
                  style={{
                    opacity: dimmed ? 0.4 : 1,
                    transform: lift
                      ? `translate(${dx.toFixed(2)}px, ${dy.toFixed(2)}px)`
                      : "translate(0px, 0px)",
                    transition:
                      "transform 150ms var(--ease-out), opacity 150ms var(--ease-out), stroke 150ms var(--ease-out)",
                  }}
                />
              );
            })}
          </g>
        </svg>

        {showCenter && (
          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center"
            style={{ padding: thickness + 6 }}
          >
            {activeSeg ? (
              <>
                <span
                  className="max-w-full truncate text-center text-[11px] font-medium text-muted-foreground"
                  style={{ maxWidth: size - thickness * 2 - 12 }}
                >
                  {activeSeg.label}
                </span>
                <span className="text-xl font-semibold tabular-nums">
                  {format.format(activeSeg.value)}
                </span>
                <span className="text-xs text-muted-foreground tabular-nums">
                  {percentFormat.format(activeSeg.value / visibleTotal)}
                </span>
              </>
            ) : (
              <>
                <NumberTicker
                  value={displayTotal}
                  duration={0.6}
                  formatOptions={formatOptions}
                  static={isStatic}
                  className="text-xl font-semibold"
                />
                {centerLabel && (
                  <span
                    className="mt-0.5 max-w-full truncate text-center text-xs text-muted-foreground"
                    style={{ maxWidth: size - thickness * 2 - 12 }}
                  >
                    {centerLabel}
                  </span>
                )}
              </>
            )}
          </div>
        )}

        {/* Cursor-anchored tooltip. */}
        {activeSeg && cursor && !reducedMotion && (
          <div
            role="status"
            className="pointer-events-none absolute z-10 flex items-center gap-2 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-overlay"
            style={{
              left: cursor.x,
              top: cursor.y,
              transform: "translate(-50%, calc(-100% - 8px))",
              whiteSpace: "nowrap",
            }}
          >
            <span
              className="size-2 shrink-0 rounded-full"
              style={{ background: colorFor(activeSeg.index) }}
            />
            <span>{activeSeg.label}</span>
            <span className="tabular-nums opacity-80">
              {format.format(activeSeg.value)}
            </span>
          </div>
        )}

        {(isEmpty || allHidden) && (
          <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
            <span className="text-xs text-muted-foreground">No data</span>
          </div>
        )}
      </div>

      {showLegend && !isEmpty && (
        <div
          className="flex min-w-32 flex-col gap-0.5"
          onPointerLeave={() => setActive(null)}
        >
          {data.map((d, i) => {
            const isHidden = hidden.has(i);
            return (
              <button
                key={i}
                type="button"
                aria-pressed={!isHidden}
                onPointerEnter={() => !isHidden && setActive(i)}
                onFocus={() => !isHidden && setActive(i)}
                onBlur={() => setActive(null)}
                onClick={() =>
                  setHidden((prev) => {
                    const next = new Set(prev);
                    if (next.has(i)) next.delete(i);
                    else next.add(i);
                    // Keep at least one series visible.
                    if (next.size === data.length) return prev;
                    return next;
                  })
                }
                className={cn(
                  "pressable flex items-center gap-2 rounded-md px-1.5 py-1 text-left text-sm outline-none transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
                  active !== null && active !== i && !isHidden && "opacity-50",
                )}
              >
                <span
                  className="size-2 shrink-0 rounded-full transition-opacity duration-150"
                  style={{
                    background: colorFor(i),
                    opacity: isHidden ? 0.35 : 1,
                  }}
                />
                <span
                  className={cn(
                    "min-w-0 truncate text-muted-foreground transition-colors",
                    isHidden && "line-through opacity-50",
                  )}
                >
                  {d.label}
                </span>
                <span
                  className={cn(
                    "ml-auto shrink-0 pl-4 font-medium tabular-nums",
                    isHidden && "opacity-50",
                  )}
                >
                  {format.format(d.value)}
                </span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}