Skip to examples
Bento / Kitchen sink
Bento / compositions

Business charts

The shapes product dashboards reach for: progress against a limit, shares, funnels, breakdowns per row, and actuals against a target. Same rules and motion as every chart.

ProgressRing

ring · gauge · over quota · unmeasured

A meter, not a picture. Each ring is announced with its name, value, and range. The arc stops at full; the number does not — 130% of a quota says 130%.

Colour is the caller's call. The ring never guesses thresholds; the storage example picks warn and crit itself.

Sourcecomponents/charts/progress-ring/doc.ts · components/charts/progress-ring/progress-ring.tsx · components/charts/progress-ring/progress-ring.module.css · components/charts/_kernel/ring.ts

components/charts/progress-ring/doc.ts

/**
 * ProgressRing — one value against a total, as a ring or a gauge.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED, what is measured
 *     value         number | null
 *     max?          default 100
 *     kind?         "ring" (full turn from 12 o'clock) | "gauge" (half turn,
 *                   left to right over the top)
 *     color?        a chart slot, or accent | warn | crit
 *     formatValue?  the centre number; default the share of max, in percent
 *     caption?      under the number
 *     size?, animation?  (default entrance: trace)
 *
 * # Behaviour
 *
 * R1  A meter: announced with its name, value, and range.
 * R2  The arc is clamped to full; the printed number never is — 130% of a
 *     quota says 130%.
 * R3  A null value draws the empty track and a dash, and is announced as
 *     not measured; it is never drawn as zero.
 * R4  Colour is the caller's decision: the ring does not guess thresholds.
 */
export {};

components/charts/progress-ring/progress-ring.tsx

"use client";

import type { CSSProperties } from "react";

import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { colorVar, type ChartColor } from "../_kernel/encode";
import { ringGeometry, ringShare, type RingKind } from "../_kernel/ring";
import { isValue } from "../_kernel/scale";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./progress-ring.module.css";

export type ProgressRingProps = {
  /** What is measured: the meter's name. */
  label: string;
  /** Null is not measured, drawn as an empty track and a dash. */
  value: number | null;
  max?: number;
  /** A ring, or a half-ring gauge. */
  kind?: RingKind;
  /** A chart slot, or a status colour for a threshold the caller decided. */
  color?: ChartColor | "accent" | "warn" | "crit";
  /** The number in the middle; default the share of max as a percentage. */
  formatValue?: (value: number, max: number) => string;
  /** Under the number: "of seats", "used". */
  caption?: string;
  /** The ring's width as a CSS length. */
  size?: string;
  /** Default: the arc traces round, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

const percentOf = (value: number, max: number) =>
  `${Math.round((value / max) * 100)}%`;

const tone = (color: ProgressRingProps["color"]) =>
  color === "accent" || color === "warn" || color === "crit"
    ? `var(--${color})`
    : colorVar(color ?? 1);

/** One value against a total. It is a meter: announced with its value, and
 *  its printed number is never clamped even when the arc is full. */
export function ProgressRing({
  label,
  value,
  max = 100,
  kind = "ring",
  color = 1,
  formatValue = percentOf,
  caption,
  size,
  animation,
  className,
}: ProgressRingProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "trace",
    axis: "x",
  });
  const measured = isValue(value);
  const shown = useTweened({ share: ringShare(value, max) }, update);
  const ring = ringGeometry(shown.share, kind);
  const text = measured ? formatValue(value, max) : "—";

  return (
    <div
      {...rootProps}
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={measured ? value : undefined}
      aria-valuetext={
        measured ? `${text}${caption ? ` ${caption}` : ""}` : "Not measured"
      }
      data-kind={kind}
      className={cn(chart.root, styles.root, className)}
      style={
        {
          "--series": tone(color),
          "--ring-size": size,
        } as CSSProperties
      }
    >
      <svg
        className={styles.svg}
        viewBox={ring.viewBox}
        data-rotate={ring.rotate || undefined}
        aria-hidden="true"
      >
        <path
          className={styles.track}
          d={ring.track}
          strokeWidth={ring.width}
        />
        {ring.value ? (
          <path
            data-mark
            className={styles.value}
            d={ring.value}
            pathLength={1}
            strokeWidth={ring.width}
          />
        ) : null}
      </svg>
      <div className={styles.centre} aria-hidden="true">
        <span className={styles.number}>{text}</span>
        {caption ? <span className={styles.caption}>{caption}</span> : null}
      </div>
    </div>
  );
}

components/charts/progress-ring/progress-ring.module.css

@layer primitive {
  .root {
    position: relative;
    display: inline-grid;
    width: var(--ring-size, 7rem);
    justify-items: center;
  }
  .svg {
    display: block;
    width: 100%;
    height: auto;
    overflow: visible;
  }
  .svg[data-rotate] {
    transform: rotate(-90deg);
  }
  .track {
    fill: none;
    stroke: var(--chart-absent, var(--surface-hover));
    stroke-linecap: round;
  }
  .value {
    fill: none;
    stroke: var(--series);
    stroke-dasharray: 1 1;
    stroke-linecap: round;
  }
  .centre {
    position: absolute;
    inset: 0;
    display: grid;
    align-content: center;
    justify-items: center;
    gap: 2px;
    text-align: center;
  }
  .root[data-kind="gauge"] .centre {
    top: auto;
    bottom: 0;
  }
  .number {
    color: var(--ink);
    font-size: calc(var(--ring-size, 7rem) * 0.2);
    font-variant-numeric: tabular-nums;
    font-weight: var(--weight-strong);
    line-height: 1;
  }
  .caption {
    max-width: 80%;
    color: var(--ink-3);
    font-size: var(--text-11);
    line-height: 1.2;
  }
}

components/charts/_kernel/ring.ts

import { arcPath } from "./donut";
import { isValue } from "./scale";

/* ProgressRing: one value against a total, as a ring or a half-ring gauge. */

export type RingKind = "ring" | "gauge";

/** The share of the total, clamped to 0–1 for drawing; the printed value is
 *  never clamped. */
export function ringShare(value: number | null, max: number) {
  if (!isValue(value) || !(max > 0)) return 0;
  return Math.max(0, Math.min(1, value / max));
}

/** Geometry for a ring (a full turn from 12 o'clock) or a gauge (a half
 *  turn over the top, 9 to 3 o'clock), in a 100-wide viewBox. */
export function ringGeometry(share: number, kind: RingKind, thickness = 0.16) {
  const width = 100 * thickness * 0.5;
  if (kind === "gauge") {
    // Unrotated: 0.5 turns is 9 o'clock, going clockwise over the top.
    const r = 50 - width / 2 - 1;
    const cy = 52;
    return {
      viewBox: `0 0 100 ${cy + width / 2 + 1}`,
      width,
      track: arcPath(0.5, 0.99999, r, 50, cy),
      value: share > 0 ? arcPath(0.5, 0.5 + share / 2, r, 50, cy) : "",
      rotate: false,
    };
  }
  const r = 50 - width / 2 - 1;
  return {
    viewBox: "0 0 100 100",
    width,
    track: arcPath(0, 0.99999, r),
    value: share > 0 ? arcPath(0, share, r) : "",
    // A full ring starts at 12: the SVG is turned a quarter.
    rotate: true,
  };
}

PieChart

a donut with thickness 1

Accounts by plan

A pie is a donut filled to the centre.

  • Free1,840 · 63.9%
  • Starter612 · 21.2%
  • Team388 · 13.5%
  • Enterprise41 · 1.4%

For two to four parts. Angles are harder to compare than lengths; past four, prefer a bar chart.

Sourcecomponents/charts/donut-chart/doc.ts · components/charts/donut-chart/donut-chart.tsx

components/charts/donut-chart/doc.ts

/**
 * DonutChart — shares of one whole.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, names the chart
 *     data         { key, label, value: number | null }[]
 *     totalLabel?  under the total in the centre, default "Total"
 *     formatValue? default: every digit
 *     thickness?   the ring's width as a share of its radius; 1 is a pie
 *     PieChart     DonutChart with thickness 1
 *     animation?   default: segments trace round in turn, when scrolled
 *                  into view
 *
 * # Behaviour
 *
 * R1  A group named by the label; the legend lists every part with its exact
 *     value and share. The ring itself is decorative.
 * R2  At most four hues: with more than five parts, the fifth onward fold
 *     into "Other (n)", drawn neutral. Five parts draw the fifth neutral.
 * R3  Zero is a measured part with no segment; null, negative, or invalid is
 *     "Unavailable" and excluded from the total.
 * R4  A total of zero draws the empty track and a dash in the centre.
 * R5  Parts keep the caller's order, clockwise from twelve o'clock.
 * R6  A pie (thickness over 0.6) has no centre total; the legend's name
 *     carries it.
 */
export {};

components/charts/donut-chart/donut-chart.tsx

"use client";

import type { CSSProperties } from "react";

import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { formatExact, formatPercent } from "../_kernel/format";
import {
  donutRing,
  donutArcs,
  donutParts,
  donutTarget,
  measuredPart,
  type DonutDatum,
} from "../_kernel/donut";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./donut-chart.module.css";

export type DonutChartProps = {
  /** Names the chart. */
  label: string;
  /** Parts of one whole. Past the fourth, parts fold into "Other". */
  data: readonly DonutDatum[];
  /** Under the total in the middle. */
  totalLabel?: string;
  formatValue?: (value: number) => string;
  /** The ring's width as a share of its radius: 1 is a pie. */
  thickness?: number;
  /** Default: each segment traces round in turn, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Shares of one whole, with the exact values and percentages beside it. For
 *  a handful of parts; past four, the rest fold into "Other", because a fifth
 *  hue would repeat one and small slices are unreadable anyway. */
export function DonutChart({
  label,
  data,
  totalLabel = "Total",
  formatValue = formatExact,
  thickness = 0.28,
  animation,
  className,
}: DonutChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "trace",
    axis: "x",
  });

  const parts = donutParts(data);
  const target = donutTarget(parts);
  const total = Object.values(target).reduce((a, b) => a + b, 0);
  const shown = useTweened(target, update);
  const arcs = donutArcs(shown, parts, thickness);
  const ring = donutRing(thickness);
  // A pie has no hole to print the total in; the legend's name carries it.
  const pie = thickness > 0.6;

  return (
    <div {...rootProps} className={cn(chart.root, styles.wrap, className)}>
      {data.length === 0 ? (
        <p className={chart.empty}>No data to display.</p>
      ) : (
        <div className={styles.layout} role="group" aria-label={label}>
          <div className={styles.ring}>
            <svg
              className={styles.svg}
              viewBox="0 0 100 100"
              aria-hidden="true"
            >
              <circle
                className={styles.track}
                cx="50"
                cy="50"
                r={ring.radius}
                strokeWidth={ring.width}
              />
              {arcs.map((part) => (
                <path
                  key={part.key}
                  data-mark
                  className={styles.arc}
                  d={part.d}
                  pathLength={1}
                  strokeWidth={ring.width}
                  style={{ "--series": part.color } as CSSProperties}
                />
              ))}
            </svg>
            {pie ? null : (
              <div className={styles.centre} aria-hidden="true">
                <span className={styles.total}>
                  {total > 0 ? formatValue(total) : "—"}
                </span>
                <span className={styles.caption}>{totalLabel}</span>
              </div>
            )}
          </div>
          <ChartLegend
            className={styles.legend}
            layout="list"
            label={`${label}: ${totalLabel.toLowerCase()} ${formatValue(total)}`}
            items={parts.map((part) => ({
              key: part.key,
              label: part.label,
              color: part.color,
              value: measuredPart(part.value)
                ? formatValue(part.value)
                : "Unavailable",
              note:
                measuredPart(part.value) && total > 0
                  ? formatPercent(part.value / total)
                  : undefined,
            }))}
          />
        </div>
      )}
    </div>
  );
}

export type { DonutDatum };

/** A donut filled to the centre. For two to four parts; past that, prefer
 *  a bar chart — angles are harder to compare than lengths. */
export function PieChart(props: Omit<DonutChartProps, "thickness">) {
  return <DonutChart {...props} thickness={1} />;
}

FunnelChart

conversion from previous and first

Signup funnel

Each stage with its conversion from the stage before and from the first.

  1. Visited pricing12,400
  2. Signed up3,10025% of previous · 25% of first
  3. Activated1,48047.7% of previous · 11.9% of first
  4. Subscribed41027.7% of previous · 3.3% of first
  5. Retained 90 days29070.7% of previous · 2.3% of first
Sourcecomponents/charts/funnel-chart/doc.ts · components/charts/funnel-chart/funnel-chart.tsx · components/charts/funnel-chart/funnel-chart.module.css · components/charts/_kernel/funnel.ts

components/charts/funnel-chart/doc.ts

/**
 * FunnelChart — stages that each keep part of the one before.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     steps         { key, label, value: number | null }[], in order
 *     color?, formatValue?, animation?  (default entrance: grow from centre)
 *
 * # Behaviour
 *
 * R1  An ordered list; every stage prints its value, and every stage after
 *     the first prints its conversion from the stage before and from the
 *     first.
 * R2  Bars are centred and measured against the largest stage.
 * R3  An unmeasured stage reads "Unavailable", draws no bar, and breaks the
 *     conversions that would need it: a gap never invents a rate.
 * R4  Stages are not reordered: the order is the process.
 */
export {};

components/charts/funnel-chart/funnel-chart.tsx

"use client";

import type { CSSProperties } from "react";

import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { colorVar, type ChartColor } from "../_kernel/encode";
import { formatExact, formatPercent } from "../_kernel/format";
import { funnelMax, funnelRows, type FunnelStep } from "../_kernel/funnel";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./funnel-chart.module.css";

export type { FunnelStep };

export type FunnelChartProps = {
  /** Names the chart. */
  label: string;
  /** In order: each stage is part of the one before. */
  steps: readonly FunnelStep[];
  color?: ChartColor;
  formatValue?: (value: number) => string;
  /** Default: stages grow out from the centre, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Stages that each keep part of the one before: signups, activated, paid.
 *  Every stage prints its value and its conversion from the stage before and
 *  from the first; an unmeasured stage breaks the conversions next to it
 *  rather than inventing them. */
export function FunnelChart({
  label,
  steps,
  color = 1,
  formatValue = formatExact,
  animation,
  className,
}: FunnelChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "x",
  });
  const rows = funnelRows(steps);
  const target: Record<string, number> = { __max: funnelMax(steps) };
  for (const row of rows)
    if (row.measured) target[row.key] = row.value as number;
  const shown = useTweened(target, update);

  return (
    <div
      {...rootProps}
      className={cn(chart.root, className)}
      style={{ "--series": colorVar(color) } as CSSProperties}
    >
      {steps.length === 0 ? (
        <p className={chart.empty}>No data to display.</p>
      ) : (
        <ol aria-label={label} className={styles.list}>
          {rows.map((row, index) => {
            const width =
              row.measured && shown.__max > 0
                ? Math.min(1, (shown[row.key] ?? 0) / shown.__max)
                : 0;
            return (
              <li key={row.key} className={styles.row}>
                <span className={styles.label}>{row.label}</span>
                <span className={styles.track} aria-hidden="true">
                  {row.measured ? (
                    <span
                      data-mark
                      className={styles.fill}
                      style={{ width: `${width * 100}%` }}
                    />
                  ) : null}
                </span>
                <span className={styles.figures}>
                  <span className={styles.value}>
                    {row.measured
                      ? formatValue(row.value as number)
                      : "Unavailable"}
                  </span>
                  {index > 0 ? (
                    <span className={styles.rate}>
                      {row.ofPrevious !== null
                        ? `${formatPercent(row.ofPrevious)} of previous`
                        : "—"}
                      {row.ofFirst !== null
                        ? ` · ${formatPercent(row.ofFirst)} of first`
                        : ""}
                    </span>
                  ) : null}
                </span>
              </li>
            );
          })}
        </ol>
      )}
    </div>
  );
}

components/charts/funnel-chart/funnel-chart.module.css

@layer primitive {
  .list {
    display: grid;
    grid-template-columns: minmax(6rem, 1fr) minmax(8rem, 3fr) auto;
    gap: var(--space-3) var(--space-5);
    margin: 0;
    padding: 0;
    list-style: none;
    font-size: var(--text-12);
  }
  .row {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: center;
  }
  .label {
    color: var(--ink-2);
  }
  /* Centred, so each stage reads as what is left of the one above. */
  .track {
    display: flex;
    height: 2rem;
    justify-content: center;
    border-radius: var(--radius-1);
    background: var(--chart-absent, var(--surface-hover));
  }
  .fill {
    height: 100%;
    border-radius: inherit;
    background: var(--series);
    transform-origin: 50% 50%;
  }
  .figures {
    display: grid;
    justify-items: end;
    font-variant-numeric: tabular-nums;
  }
  .value {
    color: var(--ink);
  }
  .rate {
    color: var(--ink-3);
    font-size: var(--text-11);
  }
}

components/charts/_kernel/funnel.ts

import { isValue } from "./scale";

/* FunnelChart: stages that each keep part of the one before. */

export type FunnelStep = { key: string; label: string; value: number | null };

export type FunnelRow = FunnelStep & {
  measured: boolean;
  /** Share of the first stage, 0–1; null when either is unmeasured. */
  ofFirst: number | null;
  /** Share of the stage before, 0–1; null for the first stage or when
   *  either is unmeasured — a gap never invents a conversion. */
  ofPrevious: number | null;
};

const measured = (v: number | null): v is number => isValue(v) && v >= 0;

export function funnelRows(steps: readonly FunnelStep[]): FunnelRow[] {
  const first = steps[0]?.value ?? null;
  return steps.map((step, index) => {
    const previous = index > 0 ? steps[index - 1].value : null;
    const ok = measured(step.value);
    return {
      ...step,
      measured: ok,
      ofFirst:
        ok && measured(first) && first > 0
          ? (step.value as number) / first
          : null,
      ofPrevious:
        index > 0 && ok && measured(previous) && previous > 0
          ? (step.value as number) / previous
          : null,
    };
  });
}

/** The value a full-width bar stands for: the largest stage. */
export function funnelMax(steps: readonly FunnelStep[]) {
  return Math.max(0, ...steps.map((s) => s.value).filter(measured));
}

StackedBarChart

stacked · percent · diverging

Hours by team

Stacked: totals compare across rows.

  • New work
  • Fixes
  • Review
  • Platform200 h
  • Growth128 h
  • Support116 h
  • Design96 h
View data for Hours by team and kind of work
Hours by team and kind of work
LabelNew workFixesReview
Platform124 h46 h30 h
Growth88 h22 h18 h
Support12 h96 h8 h
Design64 h6 h26 h
Where the time goes

Percent: each team's own split.

  • New work
  • Fixes
  • Review
  • Platform200 h
  • Growth128 h
  • Support116 h
  • Design96 h
View data for Share of hours by kind of work, per team
Share of hours by kind of work, per team
LabelNew workFixesReview
Platform124 h46 h30 h
Growth88 h22 h18 h
Support12 h96 h8 h
Design64 h6 h26 h
Customer survey

Diverging: disagreement left of centre, agreement right.

  • Strongly disagree
  • Disagree
  • Agree
  • Strongly agree
  • Easy to set up
  • Fast enough
  • Good value
  • Would recommend
View data for Survey answers per question, % of responses
Survey answers per question, % of responses
LabelStrongly disagreeDisagreeAgreeStrongly agree
Easy to set up4%9%48%39%
Fast enough8%21%44%27%
Good value12%26%40%22%
Would recommend5%11%45%39%

Percent hides the base, so it prints it. Every row fills the track, and its total is still shown beside it.

Diverging shares one scale. Both sides are measured against the larger side of the largest row, so left and right compare directly.

Sourcecomponents/charts/stacked-bar-chart/doc.ts · components/charts/stacked-bar-chart/stacked-bar-chart.tsx · components/charts/stacked-bar-chart/stacked-bar-chart.module.css · components/charts/_kernel/hbars.ts

components/charts/stacked-bar-chart/doc.ts

/**
 * StackedBarChart — horizontal bars of several parts per row.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { label, values: { [series key]: number | null } }[]
 *     series        { key, label, color?, side?: "negative" | "positive" }[]
 *     layout?       "stacked" (default) | "percent" | "diverging"
 *     formatValue?, animation?  (default entrance: grow)
 *
 * # Behaviour
 *
 * R1  stacked: parts laid end to end; every row shares one scale, so row
 *     totals compare. The total is printed.
 * R2  percent: every row fills the track; parts are shares of their row.
 *     The row's total is still printed, so the base is never hidden.
 * R3  diverging: negative-side series run left from a centre line and
 *     positive-side series run right, on one scale for both sides. List the
 *     negative series first, the one nearest neutral last.
 * R4  Only finite, non-negative values are drawn; the table lists every
 *     value, "Unavailable" for the rest.
 * R5  A legend names the series; the data table holds every exact value.
 */
export {};

components/charts/stacked-bar-chart/stacked-bar-chart.tsx

"use client";

import type { CSSProperties } from "react";

import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import type { CartesianDatum } from "../_kernel/cartesian";
import { formatExact } from "../_kernel/format";
import {
  stackedBarRows,
  stackedBarTarget,
  type SidedSeries,
  type StackedBarLayout,
} from "../_kernel/hbars";
import chart from "../_shared/chart.module.css";
import { SeriesLegend } from "../_shared/series-legend";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./stacked-bar-chart.module.css";

export type { SidedSeries, StackedBarLayout };

export type StackedBarChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  /** For "diverging", give each series a side; negative ones are listed
   *  first, the one nearest neutral last. */
  series: readonly SidedSeries[];
  /** Parts of each row's total; each row as 100%; or two sides of a centre
   *  (disagree against agree). */
  layout?: StackedBarLayout;
  formatValue?: (value: number) => string;
  /** Default: segments grow out, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Horizontal bars of several parts per row: a breakdown per team, a
 *  survey's answers. Values are in the table; totals are printed. */
export function StackedBarChart({
  label,
  data,
  series,
  layout = "stacked",
  formatValue = formatExact,
  animation,
  className,
}: StackedBarChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "x",
  });
  const shown = useTweened(stackedBarTarget(data, series, layout), update);
  const rows = stackedBarRows(shown, data, series, layout);

  if (!data.length || !series.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      <SeriesLegend series={series} lines={false} />
      <ul aria-label={label} className={styles.list}>
        {rows.map((row, index) => (
          <li key={`${index}-${row.label}`} className={styles.row}>
            <span className={styles.label}>{row.label}</span>
            <span className={styles.track} aria-hidden="true">
              {row.segments.map((segment) => (
                <span
                  key={segment.key}
                  data-mark
                  data-side={segment.side}
                  className={styles.segment}
                  style={
                    {
                      "--series": segment.color,
                      left: `${segment.left}%`,
                      width: `${segment.width}%`,
                    } as CSSProperties
                  }
                />
              ))}
              {layout === "diverging" ? (
                <span className={styles.centre} />
              ) : null}
            </span>
            <span className={styles.total}>
              {layout === "diverging" ? "" : formatValue(row.total)}
            </span>
          </li>
        ))}
      </ul>
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

components/charts/stacked-bar-chart/stacked-bar-chart.module.css

@layer primitive {
  .list {
    display: grid;
    grid-template-columns: minmax(5rem, 1fr) minmax(8rem, 4fr) auto;
    gap: var(--space-4) var(--space-5);
    margin: 0;
    padding: 0;
    list-style: none;
    font-size: var(--text-12);
  }
  .row {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: center;
  }
  .label {
    min-width: 0;
    color: var(--ink-2);
    overflow-wrap: anywhere;
  }
  .track {
    position: relative;
    height: 0.875rem;
    overflow: hidden;
    border-radius: var(--radius-1);
    background: var(--chart-absent, var(--surface-hover));
  }
  .segment {
    position: absolute;
    top: 0;
    bottom: 0;
    background: var(--series);
    transform-origin: 0 50%;
  }
  /* A hairline of the surface between segments, so neighbours read apart. */
  .segment + .segment {
    box-shadow: inset 1px 0 0 var(--surface-panel);
  }
  .segment[data-side="negative"] {
    transform-origin: 100% 50%;
  }
  .centre {
    position: absolute;
    top: -2px;
    bottom: -2px;
    left: 50%;
    width: 1px;
    background: var(--chart-axis, var(--line-strong));
  }
  .total {
    min-width: 3ch;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
    text-align: end;
  }
}

components/charts/_kernel/hbars.ts

import type { CartesianDatum } from "./cartesian";
import { seriesColor, type ChartSeries } from "./encode";
import { isValue } from "./scale";

/* StackedBarChart: horizontal bars of several series per row. */

export type StackedBarLayout = "stacked" | "percent" | "diverging";

/** A series, and (diverging only) which side of the centre it sits on. */
export type SidedSeries = ChartSeries & { side?: "negative" | "positive" };

const cell = (key: string, index: number) => `${key}|${index}`;
const measured = (v: number | null | undefined): v is number =>
  isValue(v) && v >= 0;

/** The numbers to tween: every drawable value, and the scale's end (__max):
 *  the largest row total; 100 for percent; for diverging, the larger side of
 *  the largest row, so both halves share one scale. */
export function stackedBarTarget(
  data: readonly CartesianDatum[],
  series: readonly SidedSeries[],
  layout: StackedBarLayout,
) {
  const target: Record<string, number> = {};
  let max = 0;
  data.forEach((row, index) => {
    let total = 0;
    let negative = 0;
    let positive = 0;
    for (const entry of series) {
      const v = row.values[entry.key];
      if (!measured(v)) continue;
      total += v;
      if (entry.side === "negative") negative += v;
      else positive += v;
    }
    for (const entry of series) {
      const v = row.values[entry.key];
      if (!measured(v)) continue;
      target[cell(entry.key, index)] =
        layout === "percent" ? (total > 0 ? (v / total) * 100 : 0) : v;
    }
    max = Math.max(
      max,
      layout === "diverging" ? Math.max(negative, positive) : total,
    );
  });
  target.__max = layout === "percent" ? 100 : max;
  return target;
}

export type Segment = {
  key: string;
  color: string;
  /** Left edge and width, in percent of the track. */
  left: number;
  width: number;
  side: "negative" | "positive";
};

/** Each row's segments. Stacked and percent run left to right; diverging
 *  runs outward from the centre, the last negative series nearest it. */
export function stackedBarRows(
  shown: Readonly<Record<string, number>>,
  data: readonly CartesianDatum[],
  series: readonly SidedSeries[],
  layout: StackedBarLayout,
) {
  const max = shown.__max || 1;
  const colors = new Map(
    series.map((entry, i) => [entry.key, seriesColor(entry, i)]),
  );
  return data.map((row, index) => {
    const segments: Segment[] = [];
    const at = (key: string) => shown[cell(key, index)];
    if (layout === "diverging") {
      const scale = 50 / max;
      let left = 50;
      for (const entry of series
        .filter((e) => e.side === "negative")
        .toReversed()) {
        const v = at(entry.key);
        if (v === undefined) continue;
        left -= v * scale;
        segments.push({
          key: entry.key,
          color: colors.get(entry.key)!,
          left,
          width: v * scale,
          side: "negative",
        });
      }
      let right = 50;
      for (const entry of series.filter((e) => e.side !== "negative")) {
        const v = at(entry.key);
        if (v === undefined) continue;
        segments.push({
          key: entry.key,
          color: colors.get(entry.key)!,
          left: right,
          width: v * scale,
          side: "positive",
        });
        right += v * scale;
      }
    } else {
      let left = 0;
      for (const entry of series) {
        const v = at(entry.key);
        if (v === undefined) continue;
        const width = (v / max) * 100;
        segments.push({
          key: entry.key,
          color: colors.get(entry.key)!,
          left,
          width,
          side: "positive",
        });
        left += width;
      }
    }
    let total = 0;
    for (const entry of series) {
      const v = row.values[entry.key];
      if (measured(v)) total += v;
    }
    return { label: row.label, segments, total };
  });
}
Revenue against target

Columns and a line on one axis.

  • Revenue
  • Target
Use the left and right arrow keys to read each position.
View data for Monthly revenue against target, thousands
Monthly revenue against target, thousands
LabelRevenueTarget
Apr$63.6k$60k
May$65.2k$65k
Jun$74k$70k
Jul$75.5k$75k
Aug$79.6k$80k
Sep$87k$85k
Tickets by cause

Pareto: shares and the running total, both in percent.

  • Share of total
  • Running total
Use the left and right arrow keys to read each position.
View data for Support tickets by cause, this quarter
Support tickets by cause, this quarter
ItemTicketsShareRunning total
Login and SSO41238.5%38.5%
Billing questions26825%63.5%
Failed deploys19017.7%81.2%
Usage limits969%90.2%
API errors615.7%95.9%
Other444.1%100%

No second axis. Columns and lines share one. Measures in different units belong in two charts — which is why the Pareto draws both its columns and its line in percent, with the counts in the table.

Sourcecomponents/charts/combo-chart/doc.ts · components/charts/combo-chart/combo-chart.tsx · components/charts/_kernel/combo.ts

components/charts/combo-chart/doc.ts

/**
 * ComboChart / ParetoChart — columns and lines on one axis.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     ComboChart    label, data, bars (series), lines (series), layout?
 *                   ("grouped" | "stacked"), formatValue?, formatTick?,
 *                   height?, table?, animation?, inspect?
 *     ParetoChart   label, data: { id, label, value }[], valueTitle?,
 *                   formatValue?, height?, animation?, inspect?
 *
 * # Behaviour
 *
 * R1  One y axis, shared by columns and lines. Measures in different units
 *     are not combined; they belong in two charts.
 * R2  Line points sit at the centre of their columns.
 * R3  Series keys are distinct across bars and lines.
 * R4  Pareto ranks items largest first, draws each as its share of the
 *     total, and the running total as a line — both in percent, so one axis
 *     is honest. The table has the counts, shares, and running totals.
 * R5  Inspection (inspect, default on): the pointer shows the nearest
 *     position — a crosshair or band, a marker on each line, and a card with
 *     every series' value (missing ones say "Not measured"; stacks add the
 *     total, percent stacks each share). The plot is one tab stop: ← → step,
 *     Home and End jump, Escape lets go, and each step is announced as one
 *     sentence. The card is for the eye; the table stays the full record.
 */
export {};

components/charts/combo-chart/combo-chart.tsx

"use client";

import type { CSSProperties, ReactNode } from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import type { CartesianDatum } from "../_kernel/cartesian";
import { columnGeometry } from "../_kernel/column";
import { comboTarget } from "../_kernel/combo";
import { seriesColor, type ChartSeries } from "../_kernel/encode";
import {
  formatExact,
  formatPercent,
  formatPercentTick,
  formatTick,
} from "../_kernel/format";
import { lineMarkers, readout } from "../_kernel/inspect";
import { linesGeometry } from "../_kernel/lines";
import { paretoRows, rank, type RankedDatum } from "../_kernel/ranked";
import { band, px } from "../_kernel/scale";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";

export type ComboChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  /** Drawn as columns. */
  bars: readonly ChartSeries[];
  /** Drawn as lines over the columns, on the SAME axis: there is no second
   *  axis. Measures in different units belong in two charts. */
  lines: readonly ChartSeries[];
  layout?: "grouped" | "stacked";
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  height?: string;
  /** Replace the data table (a chart that knows more than its series). */
  table?: ReactNode;
  /** Show every x label, angled: for categories that must all be read. */
  angled?: boolean;
  /** Default: columns grow, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

/** Columns with lines over them, on one axis: actual against target,
 *  volume against its moving average. */
export function ComboChart({
  label,
  data,
  bars,
  lines,
  layout = "grouped",
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  table,
  angled = false,
  animation,
  inspect = true,
  className,
}: ComboChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "y",
  });
  const { target, step, measured } = comboTarget(data, bars, lines, layout);
  const shown = useTweened(target, update);

  if (!data.length || (!bars.length && !lines.length))
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  const columns = columnGeometry(
    shown,
    data,
    bars,
    layout === "stacked",
    step,
    tickFormat,
  );
  const slots = band(data.length, 0.3);
  const trend = linesGeometry(shown, data, lines, {
    area: false,
    stacked: false,
    points: true,
    step,
    positions: (index) => slots.centre(index),
  });
  const all = [...bars, ...lines];
  const colors = all.map((entry, index) => seriesColor(entry, index));
  const trendSeries = trend.series.map((entry, index) => ({
    ...entry,
    color: colors[bars.length + index],
  }));

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      <ChartLegend
        items={all.map((entry) => {
          const inBars = bars.includes(entry);
          const index = inBars
            ? bars.indexOf(entry)
            : bars.length + lines.indexOf(entry);
          return {
            key: entry.key,
            label: entry.label,
            color: seriesColor(entry, index),
            line: inBars ? undefined : (entry.line ?? "solid"),
          };
        })}
      />
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={columns.ticks}
          height={height}
          zeroAt={columns.zeroAt}
          xLabels={
            angled
              ? data.map((row, index) => ({
                  key: `${index}-${row.label}`,
                  at: px(slots.centre(index)),
                  label: row.label,
                  align: "middle" as const,
                  tier: 2 as const,
                }))
              : columns.xLabels
          }
          angled={angled}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={columns.positions}
                band={columns.bandWidth}
                readout={(index) =>
                  readout(data[index], all, { format: formatValue, colors })
                }
                markers={(index) => lineMarkers(trendSeries, index)}
              />
            ) : null
          }
        >
          {columns.series.map((entry) => (
            <g
              key={entry.key}
              style={{ "--series": entry.color } as CSSProperties}
            >
              {entry.rects.map((rect) => (
                <rect
                  key={rect.key}
                  data-mark
                  data-negative={rect.negative || undefined}
                  className={chart.column}
                  x={rect.x}
                  y={rect.y}
                  width={rect.width}
                  height={rect.height}
                />
              ))}
            </g>
          ))}
          {trend.series.map((entry, index) => (
            <g
              key={entry.key}
              style={
                {
                  "--series": seriesColor(lines[index], bars.length + index),
                } as CSSProperties
              }
            >
              <path
                className={chart.line}
                data-line={entry.line}
                d={entry.path}
              />
              {entry.points.map((point) => (
                <path key={point.key} className={chart.point} d={point.d} />
              ))}
            </g>
          ))}
        </CartesianPlot>
      ) : (
        <p className={chart.empty}>Measurements unavailable.</p>
      )}
      {table ?? (
        <SeriesTable
          label={label}
          data={data}
          series={all}
          formatValue={formatValue}
        />
      )}
    </div>
  );
}

export type ParetoChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly RankedDatum[];
  /** What the values count, for the table. */
  valueTitle?: string;
  formatValue?: (value: number) => string;
  height?: string;
  animation?: AnimationProp;
  inspect?: boolean;
  className?: string;
};

/** Causes ranked largest first, each column its share of the total and the
 *  line the running total — both in percent, so one axis is honest. The
 *  counts are in the table. */
export function ParetoChart({
  label,
  data,
  valueTitle = "Count",
  formatValue = formatExact,
  height,
  animation,
  inspect,
  className,
}: ParetoChartProps) {
  const { measured, unmeasured } = rank(data);
  const rows = paretoRows(measured);
  return (
    <ComboChart
      label={label}
      className={className}
      height={height}
      animation={animation}
      inspect={inspect}
      formatValue={(value) => formatPercent(value / 100)}
      data={rows.map((row) => ({
        label: row.item.label,
        values: { share: row.share, cumulative: row.cumulative },
      }))}
      bars={[{ key: "share", label: "Share of total" }]}
      lines={[{ key: "cumulative", label: "Running total" }]}
      formatTick={formatPercentTick}
      angled
      table={
        <ChartData label={label}>
          <THead>
            <Tr>
              <Th>Item</Th>
              <Th numeric>{valueTitle}</Th>
              <Th numeric>Share</Th>
              <Th numeric>Running total</Th>
            </Tr>
          </THead>
          <TBody>
            {rows.map((row) => (
              <Tr key={row.item.id}>
                <Th scope="row">{row.item.label}</Th>
                <Td numeric>{formatValue(row.item.value)}</Td>
                <Td numeric>{formatPercent(row.share / 100)}</Td>
                <Td numeric>{formatPercent(row.cumulative / 100)}</Td>
              </Tr>
            ))}
            {unmeasured.map((item) => (
              <Tr key={item.id}>
                <Th scope="row">{item.label}</Th>
                <Td numeric>Unavailable</Td>
                <Td numeric>—</Td>
                <Td numeric>—</Td>
              </Tr>
            ))}
          </TBody>
        </ChartData>
      }
    />
  );
}

components/charts/_kernel/combo.ts

import type { CartesianDatum } from "./cartesian";
import { columnTarget, type ColumnLayout } from "./column";
import type { ChartSeries } from "./encode";
import { linesTarget } from "./lines";
import { niceDomain } from "./scale";

/* ComboChart: columns and lines on ONE axis. Series keys must be distinct
   across the two, since both tween in one record. */

export function comboTarget(
  data: readonly CartesianDatum[],
  bars: readonly ChartSeries[],
  lines: readonly ChartSeries[],
  layout: Exclude<ColumnLayout, "percent">,
) {
  const columns = columnTarget(data, bars, layout);
  const trend = linesTarget(data, lines, { mode: "none", zero: true });
  const { domain, step } = niceDomain([
    columns.target.__lo,
    columns.target.__hi,
    trend.target.__lo,
    trend.target.__hi,
  ]);
  return {
    target: {
      ...columns.target,
      ...trend.target,
      __lo: domain[0],
      __hi: domain[1],
    },
    step,
    measured: columns.measured || trend.measured,
  };
}

RadarChart

one scale · a broken outline

How we compare

Six qualities, one 0–10 scale.

  • Bento
  • Alternative
View data for Product qualities, Bento against an alternative, 0 to 10
Product qualities, Bento against an alternative, 0 to 10
AttributeBentoAlternative
Speed8.46.1
Reliability9.17.4
Support7.28.3
Integrations6.58.8
Value85.9
Ease of use8.86.6
One quality unmeasured

The outline breaks; nothing is guessed.

View data for Product qualities with support unmeasured
Product qualities with support unmeasured
AttributeBento
Speed8.4
Reliability9.1
SupportUnavailable
Integrations6.5
Value8
Ease of use8.8

One scale for every spoke. A radar only compares when every axis runs from the same zero to the same maximum.

Read the shape, not the area. The area depends on the order of the axes, so two identical profiles can look different sizes if their axes are ordered differently.

Sourcecomponents/charts/radar-chart/doc.ts · components/charts/radar-chart/radar-chart.tsx · components/charts/radar-chart/radar-chart.module.css · components/charts/_kernel/radar.ts

components/charts/radar-chart/doc.ts

/**
 * RadarChart — profiles across a handful of attributes.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     axes          { key, label, values: { [series key]: number | null } }[]
 *                   — one per spoke, 3–8
 *     series        one to three { key, label, color?, line? }
 *     max?          the value at the rim; default a round number above the
 *                   largest value
 *     formatValue?, formatTick?, animation?  (default: grow from the centre)
 *
 * # Behaviour
 *
 * R1  One scale for every axis, from zero at the centre to max at the rim.
 *     A radar only compares when the scale is common.
 * R2  An unmeasured axis breaks a series' outline: the measured neighbours
 *     are joined, the shape is not filled, and no vertex is guessed.
 * R3  Fewer than three axes draws nothing and says so.
 * R4  Values above max sit on the rim; the table has them exactly.
 * R5  Read the shape, not the area: the area depends on the order of axes.
 */
export {};

components/charts/radar-chart/radar-chart.tsx

"use client";

import type { CSSProperties } from "react";

import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatTick } from "../_kernel/format";
import { radarGeometry, radarTarget, type RadarAxis } from "../_kernel/radar";
import chart from "../_shared/chart.module.css";
import { SeriesLegend } from "../_shared/series-legend";
import { SeriesTable } from "../_shared/series-table";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./radar-chart.module.css";

export type { RadarAxis };

export type RadarChartProps = {
  /** Names the chart. */
  label: string;
  /** One per spoke, 3–8, each with every series' value. */
  axes: readonly RadarAxis[];
  /** One to three series; more overlap into noise. */
  series: readonly ChartSeries[];
  /** The value at the rim; default a round number above the largest. Every
   *  axis shares it: a radar only compares when the scale is common. */
  max?: number;
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** Default: shapes grow out from the centre, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Profiles across a handful of attributes on one common scale. Read the
 *  shape, not the area — the area depends on the order of the axes. */
export function RadarChart({
  label,
  axes,
  series,
  max,
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  animation,
  className,
}: RadarChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "pop",
    axis: "y",
  });
  const model = radarTarget(axes, series, max);
  const shown = useTweened(model.target, update);

  if (axes.length < 3 || !series.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>
          {axes.length
            ? "A radar needs at least three axes."
            : "No data to display."}
        </p>
      </div>
    );

  const geometry = radarGeometry(shown, axes, series, model.step, tickFormat);
  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {series.length > 1 ? <SeriesLegend series={series} lines /> : null}
      {model.measured ? (
        <div className={styles.frame}>
          <div className={styles.plot}>
            <svg
              className={styles.svg}
              viewBox="0 0 100 100"
              role="img"
              aria-label={`${label}. Exact values are in the data table.`}
            >
              {geometry.rings.map((ring) => (
                <path key={ring.value} className={styles.ring} d={ring.d} />
              ))}
              {geometry.spokes.map((spoke) => (
                <line
                  key={spoke.key}
                  className={styles.spoke}
                  x1={50}
                  y1={50}
                  x2={spoke.x2}
                  y2={spoke.y2}
                />
              ))}
              {geometry.polygons.map((polygon) => (
                <g
                  key={polygon.key}
                  data-mark
                  style={{ "--series": polygon.color } as CSSProperties}
                >
                  <path
                    className={styles.area}
                    data-open={!polygon.complete || undefined}
                    data-line={polygon.line}
                    d={polygon.path}
                  />
                  {polygon.dots.map((dot) => (
                    <path key={dot.key} className={styles.dot} d={dot.d} />
                  ))}
                </g>
              ))}
            </svg>
            <div className={styles.labels} aria-hidden="true">
              {geometry.spokes.map((spoke) => (
                <span
                  key={spoke.key}
                  data-align={spoke.align}
                  style={{ left: `${spoke.lx}%`, top: `${spoke.ly}%` }}
                >
                  {spoke.label}
                </span>
              ))}
              {geometry.rings.map((ring) => (
                <span
                  key={ring.value}
                  data-ring
                  style={{ left: `${ring.labelX}%`, top: `${ring.labelY}%` }}
                >
                  {ring.label}
                </span>
              ))}
            </div>
          </div>
        </div>
      ) : (
        <p className={chart.empty}>Measurements unavailable.</p>
      )}
      <SeriesTable
        label={label}
        data={axes}
        series={series}
        formatValue={formatValue}
        labelHeading="Attribute"
      />
    </div>
  );
}

components/charts/radar-chart/radar-chart.module.css

@layer primitive {
  /* Room around the plot for the axis labels. */
  .frame {
    width: min(100%, 30rem);
    margin-inline: auto;
    padding: var(--space-6) 5.5rem;
  }
  .plot {
    position: relative;
    aspect-ratio: 1;
  }
  .svg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  /* Series grow from the chart's centre, not their own. */
  .plot .svg [data-mark] {
    transform-box: view-box;
    transform-origin: 50% 50%;
  }
  .ring,
  .spoke {
    fill: none;
    stroke: var(--chart-grid, var(--line));
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
  .area {
    fill: color-mix(in oklab, var(--series) 18%, transparent);
    stroke: var(--series);
    stroke-linejoin: round;
    stroke-width: 2;
    vector-effect: non-scaling-stroke;
  }
  .area[data-open] {
    fill: none;
  }
  .area[data-line="dashed"] {
    stroke-dasharray: 6 4;
  }
  .area[data-line="dotted"] {
    stroke-dasharray: 0.5 4.5;
  }
  .dot {
    stroke: var(--series);
    stroke-linecap: round;
    stroke-width: 6;
    vector-effect: non-scaling-stroke;
  }
  .labels {
    position: absolute;
    inset: 0;
    color: var(--ink-2);
    font-size: var(--text-12);
    pointer-events: none;
  }
  .labels span {
    position: absolute;
    white-space: nowrap;
    transform: translate(-50%, -50%);
  }
  .labels span[data-align="start"] {
    transform: translate(0, -50%);
  }
  .labels span[data-align="end"] {
    transform: translate(-100%, -50%);
  }
  .labels span[data-ring] {
    color: var(--chart-label, var(--ink-3));
    font-family: var(--font-mono);
    font-size: var(--text-11);
    transform: translate(-50%, -50%);
  }
}

components/charts/_kernel/radar.ts

import { seriesColor, type ChartSeries } from "./encode";
import { isValue, niceDomain, px, ticksBy } from "./scale";

/* RadarChart: one spoke per axis, all on ONE scale from zero. */

/** One axis (spoke) and each series' value on it. */
export type RadarAxis = {
  key: string;
  label: string;
  values: Readonly<Record<string, number | null | undefined>>;
};

const cell = (series: string, axis: number) => `${series}|${axis}`;
export const RADAR_R = 40; // in a 100 × 100 viewBox, centred

export function radarTarget(
  axes: readonly RadarAxis[],
  series: readonly ChartSeries[],
  max?: number,
) {
  const target: Record<string, number> = {};
  const all: number[] = [];
  axes.forEach((axis, a) =>
    series.forEach((entry) => {
      const v = axis.values[entry.key];
      if (isValue(v) && v >= 0) {
        target[cell(entry.key, a)] = v;
        all.push(v);
      }
    }),
  );
  const { domain, step } = niceDomain(max !== undefined ? [max] : all, {
    count: 4,
  });
  target.__max = domain[1];
  return { target, step, measured: all.length > 0 };
}

/** Where on the plot a value on axis `a` sits: angle from 12 o'clock. */
function point(a: number, count: number, value: number, max: number) {
  const angle = (a / count) * 2 * Math.PI - Math.PI / 2;
  const r = max > 0 ? (Math.min(value, max) / max) * RADAR_R : 0;
  return [50 + r * Math.cos(angle), 50 + r * Math.sin(angle)] as const;
}

export function radarGeometry(
  shown: Readonly<Record<string, number>>,
  axes: readonly RadarAxis[],
  series: readonly ChartSeries[],
  step: number,
  format: (value: number) => string,
) {
  const n = axes.length;
  const max = shown.__max;
  const rings = ticksBy([0, max], step)
    .filter((v) => v > 0)
    .map((value) => ({
      value,
      label: format(value),
      d:
        axes
          .map((_, a) => {
            const [x, y] = point(a, n, value, max);
            return `${a ? "L" : "M"}${px(x)},${px(y)}`;
          })
          .join("") + "Z",
      // Between the first two spokes, where no axis label sits.
      ...(() => {
        const angle = (0.5 / n) * 2 * Math.PI - Math.PI / 2;
        const r = (value / max) * RADAR_R;
        return {
          labelX: 50 + r * Math.cos(angle),
          labelY: 50 + r * Math.sin(angle),
        };
      })(),
    }));
  const spokes = axes.map((axis, a) => {
    const [x, y] = point(a, n, max, max);
    const angle = (a / n) * 2 * Math.PI - Math.PI / 2;
    return {
      key: axis.key,
      label: axis.label,
      x2: px(x),
      y2: px(y),
      // Labels sit just outside the rim, anchored away from the centre.
      lx: 50 + Math.cos(angle) * (RADAR_R + 5),
      ly: 50 + Math.sin(angle) * (RADAR_R + 5),
      align:
        Math.abs(Math.cos(angle)) < 0.2
          ? "middle"
          : Math.cos(angle) > 0
            ? "start"
            : "end",
    };
  });
  const polygons = series.map((entry, s) => {
    const points = axes.map((_, a) => {
      const v = shown[cell(entry.key, a)];
      return v === undefined ? null : point(a, n, v, max);
    });
    const complete = points.every(Boolean);
    let path = "";
    if (complete) {
      path =
        points
          .map((p, a) => `${a ? "L" : "M"}${px(p![0])},${px(p![1])}`)
          .join("") + "Z";
    } else {
      // An unmeasured axis breaks the outline: no fill, no guessed vertex.
      points.forEach((p, a) => {
        const next = points[(a + 1) % n];
        if (p && next)
          path += `M${px(p[0])},${px(p[1])}L${px(next[0])},${px(next[1])}`;
      });
    }
    return {
      key: entry.key,
      color: seriesColor(entry, s),
      line: entry.line,
      complete,
      path,
      dots: points.flatMap((p, a) =>
        p ? [{ key: String(a), d: `M${px(p[0])},${px(p[1])}h0` }] : [],
      ),
    };
  });
  return { rings, spokes, polygons };
}

WaterfallChart

totals · signed changes · a missing change

MRR bridge

Where this month's recurring revenue came from and went.

  • Total
  • Increase
  • Decrease
Use the left and right arrow keys to read each position.
View data for Monthly recurring revenue bridge, thousands
Monthly recurring revenue bridge, thousands
StepChangeRunning total
Start of month—$84.2k
New+$12.4k$96.6k
Expansion+$6.1k$102.7k
Contraction−$2.3k$100.4k
Churn−$5.8k$94.6k
End of month—$94.6k

A missing change is not a zero. Hide the churn reading: the end of month becomes unavailable rather than quietly overstated.

Sourcecomponents/charts/waterfall-chart/doc.ts · components/charts/waterfall-chart/waterfall-chart.tsx · components/charts/_kernel/waterfall.ts

components/charts/waterfall-chart/doc.ts

/**
 * WaterfallChart — a level, the changes that move it, and where it lands.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     steps         { key, label, value: number | null, total?: boolean }[]
 *     formatValue?, formatTick?, height?, animation?  (default: grow)
 *     inspect?     read steps by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  A total stands on zero; a total left null is the running sum so far.
 * R2  A change floats from the running total to the new one; increases and
 *     decreases differ in colour, and every change prints its sign.
 * R3  Dashed connectors join each bar's end to the next bar's start.
 * R4  An unmeasured change is never skipped as zero: it and every level
 *     after it (until a given total) are unavailable.
 * R5  The axis includes zero. The table lists each change and the running
 *     total after it.
 * R6  Inspection (inspect, default on), as in LineChart: each step reads
 *     its change and the running total it lands on; a total reads its level;
 *     past an unmeasured change the running total reads as unknown.
 */
export {};

components/charts/waterfall-chart/waterfall-chart.tsx

"use client";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { yAxis } from "../_kernel/cartesian";
import { waterfallReadout } from "../_kernel/inspect";
import { formatExact, formatTick } from "../_kernel/format";
import { band, niceDomain, PLOT, px } from "../_kernel/scale";
import { waterfallBars, type WaterfallStep } from "../_kernel/waterfall";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./waterfall-chart.module.css";

export type { WaterfallStep };

export type WaterfallChartProps = {
  /** Names the chart. */
  label: string;
  /** In order: levels (total: true) and the changes between them. */
  steps: readonly WaterfallStep[];
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  height?: string;
  /** Default: bars grow from where they start, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

const signed = (format: (v: number) => string) => (v: number) =>
  v > 0 ? `+${format(v)}` : v < 0 ? `−${format(-v)}` : format(v);

/** A level, the changes that move it, and where it lands: an MRR bridge, a
 *  budget. Each change floats from the running total; totals stand on zero.
 *  An unmeasured change leaves every later level unknown. */
export function WaterfallChart({
  label,
  steps,
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  animation,
  inspect = true,
  className,
}: WaterfallChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "y",
  });
  const bars = waterfallBars(steps);
  const levels = bars.flatMap((bar) =>
    bar.from === null || bar.to === null ? [] : [bar.from, bar.to],
  );
  const { domain, step } = niceDomain(levels);
  const target: Record<string, number> = { __lo: domain[0], __hi: domain[1] };
  for (const bar of bars)
    if (bar.from !== null && bar.to !== null) {
      target[`${bar.key}|from`] = bar.from;
      target[`${bar.key}|to`] = bar.to;
    }
  const shown = useTweened(target, update);

  if (!steps.length)
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );

  const { y, ticks } = yAxis([shown.__lo, shown.__hi], step, tickFormat);
  const slots = band(bars.length, 0.25);
  const drawn = bars.flatMap((bar, index) => {
    const from = shown[`${bar.key}|from`];
    const to = shown[`${bar.key}|to`];
    if (from === undefined || to === undefined) return [];
    return [
      {
        bar,
        index,
        top: Math.min(y(from), y(to)),
        bottom: Math.max(y(from), y(to)),
      },
    ];
  });
  const change = signed(formatValue);

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      <ChartLegend
        items={[
          { key: "total", label: "Total", color: "var(--chart-neutral)" },
          { key: "increase", label: "Increase", color: "var(--chart-pos)" },
          { key: "decrease", label: "Decrease", color: "var(--chart-neg)" },
        ]}
      />
      {levels.length ? (
        <CartesianPlot
          label={label}
          ticks={ticks}
          height={height}
          zeroAt={shown.__lo < 0 ? px(y(0)) : undefined}
          wrap={slots.step / PLOT}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={bars.map((_, index) => px(slots.centre(index)))}
                band={px(slots.step)}
                readout={(index) => waterfallReadout(bars[index], formatValue)}
              />
            ) : null
          }
          xLabels={bars.map((bar, index) => ({
            key: bar.key,
            at: px(slots.centre(index)),
            label: bar.label,
            align: "middle" as const,
            tier: 2 as const,
          }))}
          notes={drawn.map(({ bar, index, top }) => ({
            key: bar.key,
            x: slots.centre(index) / PLOT,
            y: top / PLOT,
            text:
              bar.kind === "total"
                ? formatValue(bar.value as number)
                : change(bar.value as number),
          }))}
        >
          {drawn.map(({ bar, index }, i) => {
            const next = drawn[i + 1];
            return next && next.index === index + 1 && bar.to !== null ? (
              <line
                key={`c${bar.key}`}
                className={styles.connector}
                x1={px(slots.start(index) + slots.width)}
                x2={px(slots.start(index + 1))}
                y1={px(y(shown[`${bar.key}|to`]))}
                y2={px(y(shown[`${bar.key}|to`]))}
              />
            ) : null;
          })}
          {drawn.map(({ bar, index, top, bottom }) => (
            <rect
              key={bar.key}
              data-mark
              data-kind={bar.kind}
              className={styles.bar}
              x={px(slots.start(index))}
              width={px(slots.width)}
              y={px(top)}
              height={px(Math.max(1, bottom - top))}
            />
          ))}
        </CartesianPlot>
      ) : (
        <p className={chart.empty}>Measurements unavailable.</p>
      )}
      <ChartData label={label}>
        <THead>
          <Tr>
            <Th>Step</Th>
            <Th numeric>Change</Th>
            <Th numeric>Running total</Th>
          </Tr>
        </THead>
        <TBody>
          {bars.map((bar) => (
            <Tr key={bar.key}>
              <Th scope="row">{bar.label}</Th>
              <Td numeric>
                {bar.kind === "total"
                  ? "—"
                  : bar.value !== null
                    ? change(bar.value)
                    : "Unavailable"}
              </Td>
              <Td numeric>
                {bar.to !== null ? formatValue(bar.to) : "Unavailable"}
              </Td>
            </Tr>
          ))}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/_kernel/waterfall.ts

import { isValue } from "./scale";

/* WaterfallChart: a start, the changes, and where they land. */

export type WaterfallStep = {
  key: string;
  label: string;
  /** A change, or (total: true) a level. A total left null is the running
   *  sum so far. */
  value: number | null;
  total?: boolean;
};

export type WaterfallBar = {
  key: string;
  label: string;
  kind: "total" | "increase" | "decrease" | "unavailable";
  /** Where the bar runs from and to; null when it cannot be known. */
  from: number | null;
  to: number | null;
  /** The change (or level, for totals) as given or computed. */
  value: number | null;
};

/** Running totals through the steps. An unmeasured change makes every
 *  later level unknown — it is never skipped as zero. */
export function waterfallBars(steps: readonly WaterfallStep[]): WaterfallBar[] {
  let level: number | null = 0;
  return steps.map((step) => {
    if (step.total) {
      const value = isValue(step.value) ? step.value : level;
      if (isValue(step.value)) level = step.value;
      return {
        key: step.key,
        label: step.label,
        kind: value === null ? "unavailable" : "total",
        from: value === null ? null : 0,
        to: value,
        value,
      };
    }
    if (!isValue(step.value) || level === null) {
      level = null;
      return {
        key: step.key,
        label: step.label,
        kind: "unavailable",
        from: null,
        to: null,
        value: isValue(step.value) ? step.value : null,
      };
    }
    const from: number = level;
    // Rounded to 12 significant digits: summing decimals must not print
    // 94.60000000000001.
    level = Number((from + step.value).toPrecision(12));
    return {
      key: step.key,
      label: step.label,
      kind: step.value >= 0 ? "increase" : "decrease",
      from,
      to: level,
      value: step.value,
    };
  });
}

CalendarHeatmap

a year · four levels · zero and no data

Deploys

Every day of the last year. The outlined week in March had no data.

View data for Deploys per day, last 12 months
Deploys per day, last 12 months
MonthTotalActive daysDays with dataBusiest day
Sep 20256342025-09-30 (4)
Oct 20256321312025-10-10 (5)
Nov 20257022302025-11-04 (6)
Dec 20256723312025-12-12 (7)
Jan 20267921312026-01-07 (8)
Feb 202610924282026-02-24 (9)
Mar 20266417242026-03-17 (9)
Apr 202611627302026-04-20 (10)
May 20269321312026-05-13 (10)
Jun 202612725302026-06-10 (11)
Jul 202616026312026-07-10 (12)
Aug 202612627312026-08-12 (13)
Sep 202613322262026-09-03 (13)

Zero and no data differ. A day measured as nothing is filled with the empty level; a day with no data is outlined. The picture is named with its summary, and the table has each month.

Sourcecomponents/charts/calendar-heatmap/doc.ts · components/charts/calendar-heatmap/calendar-heatmap.tsx · components/charts/calendar-heatmap/calendar-heatmap.module.css · components/charts/_kernel/calendar.ts

components/charts/calendar-heatmap/doc.ts

/**
 * CalendarHeatmap — a span of days, a week to a column.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     days          { date: "YYYY-MM-DD", value: number | null }[]
 *     from, to      the first and last day shown
 *     unit?         what a day's number counts, for the summary
 *     formatValue?, animation?  (default: fade)
 *
 * # Behaviour
 *
 * R1  Weeks run left to right, Monday at the top. Dates are UTC calendar
 *     days, so every time zone draws the same grid.
 * R2  Four states: a value (one of four levels, by quartile of the busy
 *     days); zero (filled, the empty level); no data — a day with no entry
 *     or a null (outlined); and outside the range (not drawn).
 * R3  The picture is named with a summary: total, active days, and the
 *     busiest day. Each day carries its date and value as a tooltip.
 * R4  The table summarises by month: total, active days, days with data,
 *     and the busiest day.
 */
export {};

components/charts/calendar-heatmap/calendar-heatmap.tsx

"use client";

import type { CSSProperties } from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { cn } from "@/lib/utils/cn";
import {
  calendarCells,
  monthLabels,
  monthSummary,
  type CalendarDay,
} from "../_kernel/calendar";
import { sequentialFill } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./calendar-heatmap.module.css";

export type { CalendarDay };

export type CalendarHeatmapProps = {
  /** Names the chart. */
  label: string;
  /** One entry per day, as YYYY-MM-DD. A day with no entry has no data —
   *  not zero; send 0 for a day measured as nothing. */
  days: readonly CalendarDay[];
  /** The first and last day shown, YYYY-MM-DD (UTC calendar days). */
  from: string;
  to: string;
  /** What a day's number is, for the summary: "deploys". */
  unit?: string;
  formatValue?: (value: number) => string;
  /** Default: days fade in, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

const WEEKDAYS = ["Mon", "", "Wed", "", "Fri", "", ""];

/** A year (or any span) of days, a week to a column: activity at a glance.
 *  Four levels by quartile of the busy days; a measured zero is filled, a
 *  day without data is outlined. The exact numbers are summarised by month
 *  in the table. */
export function CalendarHeatmap({
  label,
  days,
  from,
  to,
  unit = "",
  formatValue = formatExact,
  animation,
  className,
}: CalendarHeatmapProps) {
  const { rootProps } = useChartMotion(animation, { enter: "fade", axis: "y" });
  const { cells } = calendarCells(days, from, to);
  const months = monthLabels(cells);
  const summary = monthSummary(cells);
  const inRange = cells.filter((c) => c.state !== "outside");
  const total = inRange.reduce((sum, c) => sum + (c.value ?? 0), 0);
  const active = inRange.filter((c) => (c.value ?? 0) > 0).length;
  const busiest = inRange.reduce<(typeof cells)[number] | null>(
    (best, c) =>
      c.value !== null && (!best || c.value > (best.value ?? 0)) ? c : best,
    null,
  );
  const suffix = unit ? ` ${unit}` : "";
  const description = `${label}: ${formatValue(total)}${suffix} over ${active} active days${
    busiest?.value
      ? `; busiest ${busiest.date} with ${formatValue(busiest.value)}`
      : ""
  }.`;

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      <div className={styles.scroll}>
        <div className={styles.calendar} role="img" aria-label={description}>
          <div className={styles.months} aria-hidden="true">
            {months.map((month) => (
              <span
                key={month.week}
                style={{ "--week": month.week } as CSSProperties}
              >
                {month.label}
              </span>
            ))}
          </div>
          <div className={styles.days} aria-hidden="true">
            {WEEKDAYS.map((day, index) => (
              <span key={index}>{day}</span>
            ))}
          </div>
          <div className={styles.grid} aria-hidden="true">
            {cells.map((cell) => (
              <span
                key={cell.date}
                data-mark={
                  cell.state === "value" || cell.state === "zero" || undefined
                }
                data-state={cell.state}
                className={styles.day}
                title={
                  cell.state === "outside"
                    ? undefined
                    : `${cell.date}: ${cell.value === null ? "no data" : formatValue(cell.value)}${suffix}`
                }
                style={
                  cell.state === "value"
                    ? ({
                        "--fill": sequentialFill(cell.level / 4),
                      } as CSSProperties)
                    : undefined
                }
              />
            ))}
          </div>
        </div>
      </div>
      <div className={styles.key} aria-hidden="true">
        Less
        {[1, 2, 3, 4].map((level) => (
          <span
            key={level}
            className={styles.day}
            style={{ "--fill": sequentialFill(level / 4) } as CSSProperties}
          />
        ))}
        More
        <span className={styles.gap} />
        <span className={styles.day} data-state="zero" /> None
        <span className={styles.day} data-state="missing" /> No data
      </div>
      <ChartData label={label}>
        <THead>
          <Tr>
            <Th>Month</Th>
            <Th numeric>Total</Th>
            <Th numeric>Active days</Th>
            <Th numeric>Days with data</Th>
            <Th>Busiest day</Th>
          </Tr>
        </THead>
        <TBody>
          {summary.map((month) => (
            <Tr key={month.month}>
              <Th scope="row">{month.month}</Th>
              <Td numeric>{formatValue(month.total)}</Td>
              <Td numeric>{month.active}</Td>
              <Td numeric>{month.measured}</Td>
              <Td>
                {month.best?.value
                  ? `${month.best.date} (${formatValue(month.best.value)})`
                  : "—"}
              </Td>
            </Tr>
          ))}
        </TBody>
      </ChartData>
    </div>
  );
}

components/charts/calendar-heatmap/calendar-heatmap.module.css

@layer primitive {
  .scroll {
    min-width: 0;
    overflow-x: auto;
    padding-bottom: var(--space-2);
  }
  .calendar {
    --cell: 11px;
    --gap: 3px;
    display: grid;
    width: max-content;
    grid-template-columns: auto auto;
    column-gap: var(--space-3);
    color: var(--chart-label, var(--ink-3));
    font-size: var(--text-11);
  }
  .months {
    position: relative;
    height: 1.4em;
    grid-column: 2;
  }
  .months span {
    position: absolute;
    top: 0;
    left: calc(var(--week) * (var(--cell) + var(--gap)));
  }
  .days {
    display: grid;
    grid-template-rows: repeat(7, var(--cell));
    gap: var(--gap);
    line-height: var(--cell);
  }
  .grid {
    display: grid;
    grid-auto-columns: var(--cell);
    grid-auto-flow: column;
    grid-template-rows: repeat(7, var(--cell));
    gap: var(--gap);
  }
  .day {
    border-radius: 2px;
    background: var(--fill, var(--chart-absent));
  }
  .day[data-state="zero"] {
    --fill: var(--chart-absent);
  }
  .day[data-state="missing"] {
    border: 1px dashed var(--ink-4, var(--line-strong));
    background: transparent;
  }
  .day[data-state="outside"] {
    visibility: hidden;
  }
  .key {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .key .day {
    display: inline-block;
    width: 11px;
    height: 11px;
  }
  .key .gap {
    width: var(--space-5);
  }
}

components/charts/_kernel/calendar.ts

import { isValue } from "./scale";

/* CalendarHeatmap: days in week columns, Monday first. All dates are UTC
   calendar days, so server and browser agree whatever their time zones. */

export type CalendarDay = { date: string; value: number | null };

export type CalendarCell = {
  date: string;
  /** 0 Monday … 6 Sunday. */
  weekday: number;
  week: number;
  state: "value" | "zero" | "missing" | "outside";
  value: number | null;
  /** 1–4 for values: which quartile of the non-zero values. */
  level: number;
};

const DAY = 86_400_000;
const parse = (date: string) => Date.parse(`${date}T00:00:00Z`);
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
const weekday = (ms: number) => (new Date(ms).getUTCDay() + 6) % 7;

/** Every day from `from` to `to`, padded to whole weeks. Days in range with
 *  no entry are "missing" (no data); padding days are "outside". */
export function calendarCells(
  days: readonly CalendarDay[],
  from: string,
  to: string,
) {
  const byDate = new Map(days.map((d) => [d.date, d.value]));
  const start = parse(from);
  const end = parse(to);
  const first = start - weekday(start) * DAY;
  const last = end + (6 - weekday(end)) * DAY;
  const positives = days
    .map((d) => d.value)
    .filter((v): v is number => isValue(v) && v > 0)
    .toSorted((a, b) => a - b);
  const cut = (p: number) =>
    positives.length
      ? positives[
          Math.min(positives.length - 1, Math.floor(p * positives.length))
        ]
      : 0;
  const cuts = [cut(0.25), cut(0.5), cut(0.75)];
  const cells: CalendarCell[] = [];
  for (let ms = first, i = 0; ms <= last; ms += DAY, i++) {
    const date = iso(ms);
    const inRange = ms >= start && ms <= end;
    const raw = byDate.get(date);
    const has = byDate.has(date) && isValue(raw);
    const value = has ? (raw as number) : null;
    cells.push({
      date,
      weekday: weekday(ms),
      week: Math.floor(i / 7),
      state: !inRange
        ? "outside"
        : !has
          ? "missing"
          : value === 0
            ? "zero"
            : "value",
      value,
      level: value && value > 0 ? 1 + cuts.filter((c) => value > c).length : 0,
    });
  }
  return { cells, weeks: Math.ceil(cells.length / 7) };
}

const MONTHS = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

/** A label over the first week column of each month. */
export function monthLabels(cells: readonly CalendarCell[]) {
  const out: { week: number; label: string }[] = [];
  let seen = "";
  for (const cell of cells) {
    if (cell.state === "outside" || cell.weekday !== 0) continue;
    const month = cell.date.slice(0, 7);
    if (month !== seen) {
      seen = month;
      out.push({
        week: cell.week,
        label: MONTHS[Number(cell.date.slice(5, 7)) - 1],
      });
    }
  }
  // A month that starts too close to the next (a partial first month) would
  // print on top of it.
  return out.filter(
    (label, i) => !out[i + 1] || out[i + 1].week - label.week >= 3,
  );
}

/** Per-month summary for the data table. */
export function monthSummary(cells: readonly CalendarCell[]) {
  const months = new Map<
    string,
    {
      total: number;
      active: number;
      measured: number;
      best: CalendarCell | null;
    }
  >();
  for (const cell of cells) {
    if (cell.state === "outside") continue;
    const key = cell.date.slice(0, 7);
    const m = months.get(key) ?? {
      total: 0,
      active: 0,
      measured: 0,
      best: null,
    };
    if (cell.value !== null) {
      m.measured++;
      m.total += cell.value;
      if (cell.value > 0) m.active++;
      if (!m.best || cell.value > (m.best.value ?? 0)) m.best = cell;
    }
    months.set(key, m);
  }
  return [...months].map(([month, m]) => ({
    month: `${MONTHS[Number(month.slice(5, 7)) - 1]} ${month.slice(0, 4)}`,
    ...m,
  }));
}

SunburstChart

a hierarchy · a hue per branch

Storage

By team, then by project.

  • Data380 GB · 39%
  • Platform420 GB · 43.1%
  • Growth105 GB · 10.8%
  • Design70 GB · 7.2%
View data for Storage by team and project, GB
Storage by team and project, GB
PartValueShare of total
Data380 GB39%
Data / Warehouse300 GB30.8%
Data / Exports80 GB8.2%
Platform420 GB43.1%
Platform / atlas-db210 GB21.5%
Platform / atlas-api120 GB12.3%
Platform / Logs90 GB9.2%
Growth105 GB10.8%
Growth / Website60 GB6.2%
Growth / Experiments45 GB4.6%
Design70 GB7.2%
Design / Assets70 GB7.2%
Sourcecomponents/charts/sunburst-chart/doc.ts · components/charts/sunburst-chart/sunburst-chart.tsx · components/charts/_kernel/sunburst.ts

components/charts/sunburst-chart/doc.ts

/**
 * SunburstChart — parts of a whole, and the parts of those parts.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label         REQUIRED
 *     data          { key, label, value?, children? }[] — the top level
 *     totalLabel?, formatValue?, animation?  (default: trace)
 *
 * # Behaviour
 *
 * R1  The top level is the inner ring; each child sits outside its parent,
 *     spanning its share of the parent. Up to three rings are drawn.
 * R2  A parent's value is the sum of its children; only leaves carry
 *     values. Non-positive or non-finite leaves count as nothing.
 * R3  Each top-level part takes a hue (four, then neutral) and its
 *     descendants take lighter tints of it, so a branch reads as one family.
 * R4  The legend lists the top level with values and shares; the table
 *     lists every node by its full path.
 */
export {};

components/charts/sunburst-chart/sunburst-chart.tsx

"use client";

import type { CSSProperties } from "react";

import { TBody, Td, Th, THead, Tr } from "@/components/display/table";
import type { AnimationProp } from "@/lib/motion";
import { useTweened } from "@/lib/motion/react";
import { cn } from "@/lib/utils/cn";
import { ChartLegend } from "../chart-legend";
import { formatExact, formatPercent } from "../_kernel/format";
import {
  sunburstArcs,
  sunburstPath,
  sunburstRing,
  sunburstRows,
  sunburstTarget,
  type SunburstNode,
} from "../_kernel/sunburst";
import { ChartData } from "../_shared/chart-data";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./sunburst-chart.module.css";

export type { SunburstNode };

export type SunburstChartProps = {
  /** Names the chart. */
  label: string;
  /** The top level; children nest outward, up to three rings. */
  data: readonly SunburstNode[];
  /** Under the total in the middle. */
  totalLabel?: string;
  formatValue?: (value: number) => string;
  /** Default: arcs trace round, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

const depthOf = (nodes: readonly SunburstNode[]): number =>
  nodes.length
    ? 1 + Math.max(...nodes.map((n) => depthOf(n.children ?? [])))
    : 0;

/** Parts of a whole, and the parts of those parts: storage by team and
 *  then by project. Each top-level part keeps its hue as it splits. The
 *  legend names the top level; the table has every level. */
export function SunburstChart({
  label,
  data,
  totalLabel = "Total",
  formatValue = formatExact,
  animation,
  className,
}: SunburstChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "trace",
    axis: "x",
  });
  const shown = useTweened(sunburstTarget(data), update);
  const depths = Math.min(3, depthOf(data));
  const { arcs, total } = sunburstArcs(data, shown, depths);
  const exactTotal = sunburstRows(data, 0)
    .filter((row) => row.depth === 0)
    .reduce((sum, row) => sum + row.value, 0);
  const rows = sunburstRows(data, exactTotal);

  return (
    <div {...rootProps} className={cn(chart.root, styles.wrap, className)}>
      {!data.length ? (
        <p className={chart.empty}>No data to display.</p>
      ) : (
        <div className={styles.layout} role="group" aria-label={label}>
          <div className={styles.ring}>
            <svg
              className={styles.svg}
              viewBox="0 0 100 100"
              aria-hidden="true"
            >
              {arcs.map((arc) => (
                <path
                  key={arc.path}
                  data-mark
                  className={styles.arc}
                  d={sunburstPath(arc, depths)}
                  pathLength={1}
                  strokeWidth={sunburstRing(arc.depth, depths).width}
                  style={{ "--series": arc.color } as CSSProperties}
                />
              ))}
            </svg>
            <div className={styles.centre} aria-hidden="true">
              <span className={styles.total}>
                {total > 0 ? formatValue(exactTotal) : "—"}
              </span>
              <span className={styles.caption}>{totalLabel}</span>
            </div>
          </div>
          <ChartLegend
            className={styles.legend}
            layout="list"
            label={`${label}: ${totalLabel.toLowerCase()} ${formatValue(exactTotal)}`}
            items={data.map((node) => {
              const value =
                rows.find((row) => row.depth === 0 && row.key === node.key)
                  ?.value ?? 0;
              return {
                key: node.key,
                label: node.label,
                color:
                  arcs.find((arc) => arc.path === node.key)?.color ??
                  "var(--chart-neutral)",
                value: formatValue(value),
                note:
                  exactTotal > 0
                    ? formatPercent(value / exactTotal)
                    : undefined,
              };
            })}
          />
        </div>
      )}
      {data.length ? (
        <ChartData label={label}>
          <THead>
            <Tr>
              <Th>Part</Th>
              <Th numeric>Value</Th>
              <Th numeric>Share of total</Th>
            </Tr>
          </THead>
          <TBody>
            {rows.map((row) => (
              <Tr key={row.path}>
                <Th scope="row">{row.path}</Th>
                <Td numeric>{formatValue(row.value)}</Td>
                <Td numeric>
                  {row.share !== null ? formatPercent(row.share) : "—"}
                </Td>
              </Tr>
            ))}
          </TBody>
        </ChartData>
      ) : null}
    </div>
  );
}

components/charts/_kernel/sunburst.ts

import { arcPath } from "./donut";
import { colorVar, type ChartColor } from "./encode";
import { isValue } from "./scale";

/* SunburstChart: a hierarchy as rings, parents inside their children. */

export type SunburstNode = {
  key: string;
  label: string;
  /** Leaves carry a value; a parent's value is the sum of its children. */
  value?: number | null;
  children?: readonly SunburstNode[];
};

export type SunburstArc = {
  /** Keys from the top level down, joined with "/". */
  path: string;
  label: string;
  depth: number;
  value: number;
  color: string;
  /** Turns. */
  start: number;
  end: number;
};

const valueOf = (node: SunburstNode): number => {
  if (node.children?.length)
    return node.children.reduce((sum, c) => sum + valueOf(c), 0);
  return isValue(node.value) && node.value > 0 ? node.value : 0;
};

/** Every node's value by path, for tweening. */
export function sunburstTarget(
  nodes: readonly SunburstNode[],
  prefix = "",
): Record<string, number> {
  const out: Record<string, number> = {};
  for (const node of nodes) {
    const path = prefix ? `${prefix}/${node.key}` : node.key;
    out[path] = valueOf(node);
    if (node.children?.length)
      Object.assign(out, sunburstTarget(node.children, path));
  }
  return out;
}

/** Arcs for the tweened values. Each top-level node takes a hue (four, then
 *  neutral); its descendants take lighter tints of it by depth. */
export function sunburstArcs(
  nodes: readonly SunburstNode[],
  shown: Readonly<Record<string, number>>,
  maxDepth = 3,
) {
  const arcs: SunburstArc[] = [];
  const walk = (
    level: readonly SunburstNode[],
    prefix: string,
    depth: number,
    start: number,
    span: number,
    parentTotal: number,
    hue: string | null,
  ) => {
    let cursor = start;
    level.forEach((node, index) => {
      const path = prefix ? `${prefix}/${node.key}` : node.key;
      const value = shown[path] ?? 0;
      const share = parentTotal > 0 ? (value / parentTotal) * span : 0;
      const base =
        hue ?? colorVar(index < 4 ? ((index + 1) as ChartColor) : "neutral");
      const color =
        depth === 0
          ? base
          : `color-mix(in oklab, ${base} ${100 - depth * 28}%, var(--surface-panel))`;
      if (share > 0) {
        arcs.push({
          path,
          label: node.label,
          depth,
          value,
          color,
          start: cursor,
          end: cursor + share,
        });
        if (node.children?.length && depth + 1 < maxDepth)
          walk(node.children, path, depth + 1, cursor, share, value, base);
      }
      cursor += share;
    });
  };
  const total = nodes.reduce((sum, n) => sum + (shown[n.key] ?? 0), 0);
  walk(nodes, "", 0, 0, 1, total, null);
  return { arcs, total };
}

/** A ring's radius and width by depth, in a 100 × 100 viewBox. */
export function sunburstRing(depth: number, depths: number) {
  const inner = 16;
  const outer = 47;
  const width = (outer - inner) / depths;
  return { radius: inner + width * (depth + 0.5), width: width - 1 };
}

export const sunburstPath = (arc: SunburstArc, depths: number) => {
  const { radius } = sunburstRing(arc.depth, depths);
  const gap = arc.end - arc.start > 0.006 ? 0.0015 : 0;
  return arcPath(arc.start + gap, arc.end - gap, radius);
};

/** Every node as rows for the data table: its path, value, and share of
 *  the whole. */
export function sunburstRows(nodes: readonly SunburstNode[], total: number) {
  const rows: {
    key: string;
    path: string;
    label: string;
    depth: number;
    value: number;
    share: number | null;
  }[] = [];
  const walk = (
    level: readonly SunburstNode[],
    prefix: string,
    depth: number,
  ) => {
    for (const node of level) {
      const label = prefix ? `${prefix} / ${node.label}` : node.label;
      const value = valueOf(node);
      rows.push({
        key: node.key,
        path: label,
        label: node.label,
        depth,
        value,
        share: total > 0 ? value / total : null,
      });
      if (node.children?.length) walk(node.children, label, depth + 1);
    }
  };
  walk(nodes, "", 0);
  return rows;
}