Donut Chart
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;
}

/**
 * A hand-built SVG donut. Each segment is a dash on a normalized-pathLength
 * circle: dash length = its exact fraction of the visible total minus the gap,
 * dash offset = the running cumulative fraction. Because both are plain CSS
 * stroke properties, toggling a legend row RE-LAYS OUT the ring smoothly —
 * every remaining segment glides to its new angle instead of jumping. Gaps are
 * exact (butt caps), and the whole ring sweeps in once through a mask on first
 * view while the center value counts up.
 *
 * Interactive: hover, tap, or focus a segment (or its legend row) to lift it
 * 2px outward and dim the rest, with a cursor-anchored tooltip showing value
 * and share; click a legend row to toggle its segment out of the total.
 * Reduced motion renders the final state immediately (tooltips stay).
 */
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 visibleCount = data.reduce(
    (count, _, i) => count + (hidden.has(i) ? 0 : 1),
    0,
  );
  const isEmpty = data.length === 0;
  const allHidden = !isEmpty && visibleCount === 0;

  // Segment layout on a normalized (pathLength = 1) circle. `start` is the
  // running cumulative fraction; the gap is carved out of each dash equally
  // on both ends so shares stay exact. A single visible segment closes into
  // a full ring (no gap against itself).
  const gapFrac = (visibleCount > 1 ? Math.max(gap, 0) : 0) / 360;
  let running = 0;
  const segments = data.map((d, i) => {
    const isHidden = hidden.has(i);
    const sweep = isHidden ? 0 : d.value / visibleTotal;
    const start = running;
    running += sweep;
    // Keep a sliver visible for real-but-tiny segments the gap would swallow.
    const len =
      sweep <= 0 ? 0 : Math.max(sweep - gapFrac, Math.min(sweep, 0.002));
    const offset = start + (sweep - len) / 2;
    const midDeg = (start + sweep / 2) * 360;
    const [ox, oy] = polar(0, 0, 1, midDeg); // outward unit vector
    return {
      ...d,
      index: i,
      isHidden,
      sweep,
      len,
      offset,
      out: [ox, oy] as const,
    };
  });

  const activeSeg =
    active !== null && !segments[active]?.isHidden ? 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}
        onPointerDown={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 calc(650ms * var(--duration-scale, 1)) 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) => {
              const isActive = active === segment.index;
              const dimmed = active !== null && !isActive;
              const dx = segment.out[0] * 2;
              const dy = segment.out[1] * 2;
              const lift = isActive && !reducedMotion;
              // Hidden segments stay mounted at zero length so a legend
              // toggle animates the ring re-layout instead of jumping.
              return (
                <circle
                  key={segment.index}
                  role="img"
                  tabIndex={segment.isHidden ? -1 : 0}
                  aria-hidden={segment.isHidden || undefined}
                  aria-label={`${segment.label}: ${format.format(
                    segment.value,
                  )}, ${percentFormat.format(segment.value / visibleTotal)}`}
                  cx={c}
                  cy={c}
                  r={r}
                  fill="none"
                  stroke={colorFor(segment.index)}
                  strokeWidth={thickness}
                  pathLength={1}
                  strokeDasharray={`${segment.len} ${1 - segment.len}`}
                  strokeDashoffset={-segment.offset}
                  onPointerEnter={() =>
                    !segment.isHidden && setActive(segment.index)
                  }
                  onFocus={() => !segment.isHidden && setActive(segment.index)}
                  onBlur={() => setActive(null)}
                  onKeyDown={(e) => {
                    if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
                    e.preventDefault();
                    const dir = e.key === "ArrowRight" ? 1 : -1;
                    const focusables = Array.from(
                      e.currentTarget.parentElement?.querySelectorAll(
                        "circle[tabindex='0']",
                      ) ?? [],
                    ) as SVGCircleElement[];
                    const at = focusables.indexOf(e.currentTarget);
                    focusables[
                      (at + dir + focusables.length) % focusables.length
                    ]?.focus();
                  }}
                  className="cursor-pointer outline-none focus-visible:stroke-foreground"
                  style={{
                    opacity: dimmed ? 0.4 : 1,
                    transformOrigin: `${c}px ${c}px`,
                    transform: `${
                      lift
                        ? `translate(${dx.toFixed(2)}px, ${dy.toFixed(2)}px) `
                        : ""
                    }rotate(-90deg)`,
                    transition:
                      "stroke-dasharray 400ms var(--ease-out), stroke-dashoffset 400ms var(--ease-out), 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 — informational, so it survives reduced motion. */}
        {activeSeg && cursor && (
          <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(${
                cursor.x > size * 0.55 ? "calc(-100% + 8px)" : "-8px"
              }, 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>
            <span className="tabular-nums opacity-60">
              {percentFormat.format(activeSeg.value / visibleTotal)}
            </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,scale] duration-150 ease-out"
                  style={{
                    background: colorFor(i),
                    opacity: isHidden ? 0.35 : 1,
                    scale: isHidden ? "0.6" : "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>
  );
}