Skip to examples
Bento / Kitchen sink
Bento / compositions

Charts

Ordinary product metrics: trends, breakdowns, shares. Every chart names itself, keeps its exact values available as text, and never turns a missing measurement into zero. Motion is data: every chart takes an animation prop, and reduced motion always wins. Point at a chart, or Tab into it and use the arrow keys, to read it position by position.

ChartFrame and LineChart

series · gaps · range change animates

Active workspaces

Workspaces with at least one sign-in in the month.

  • This year
  • Last year
Use the left and right arrow keys to read each position.
View data for Active workspaces by month
Active workspaces by month
LabelThis yearLast year
Oct1,180840
Nov1,242876
Dec1,204910
Jan1,310902
Feb1,398954
Mar1,4671,003
Apr1,5201,041
May1,6041,066
Jun1,6881,102
Jul1,7311,150
Aug1,8401,162
Sep1,9621,175

Change the range and the lines and axis move to the new data instead of jumping: charts tween their values, then redraw from the in-between values each frame.

The plot stretches; the text does not. The SVG scales to its container and the axis labels are HTML placed by percentage, so labels stay crisp at any width. Narrow, every other x label hides.

Point at it, or Tab into it. The pointer shows every series' value at the nearest position; from the keyboard, ← and → step, Home and End jump, and Escape lets go, and each step is announced. Stacks add the total, percent stacks each share, and a gap reads “Not measured”.

Sourcecomponents/charts/doc.ts · components/charts/chart-frame/doc.ts · components/charts/chart-frame/chart-frame.tsx · components/charts/chart-frame/chart-frame.module.css · components/charts/chart-legend/doc.ts · components/charts/chart-legend/chart-legend.tsx · components/charts/_kernel/inspect.ts · components/charts/_shared/chart-inspector.tsx · components/charts/line-chart/doc.ts · components/charts/line-chart/line-chart.tsx

components/charts/doc.ts

/**
 * charts — pictures of ordinary product metrics.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  Every chart has a required name (`label`), and its exact values are
 *     available as text: printed beside the marks, in a legend, or in a data
 *     table behind "View data".
 * R2  A missing or invalid measurement (null, NaN, ±Infinity) is never drawn
 *     as zero. It breaks a line, draws no column, and reads "Unavailable".
 *     Zero is a measurement and is drawn.
 * R3  No rows says "No data to display"; rows with nothing measured say
 *     "Measurements unavailable", and the table still lists the rows.
 * R4  Colour is four categorical slots, then neutral. The palette never
 *     cycles. Series can also differ by line style, which survives greyscale.
 * R5  Every chart takes `animation` (see lib/motion): an entrance for its
 *     marks, and an update timing for new data. Reduced motion always wins.
 * R6  Axis text stays at reading size at any width; narrow plots show fewer
 *     x labels rather than smaller ones.
 * R7  Numbers print in one fixed locale, so server and client agree.
 *
 * # Members
 *
 *     ChartFrame    figure: title, description, actions, chart, footer
 *     ChartLegend   which colour and line style is which
 *     LineChart     values over ordered positions; AreaChart fills to zero
 *                   or stacks
 *     ColumnChart   vertical columns, grouped or stacked
 *     BarChart      labelled horizontal bars with values printed
 *     Sparkline     a trend without axes, for cards and cells
 *     DonutChart    shares of one whole, with a value legend
 *
 *     PlotFrame     two numeric axes and a plot area, for the charts below
 *     Volcano       effect against significance, three states
 *     BubblePlot    two measures by position, a third by area
 *     RankedBar     columns sorted by the chart, values printed
 *     Matrix        a table of coloured cells, four kinds of cell
 *     ColourBar     the stepped scale a colour-coded chart uses
 *
 *     ProgressRing  one value against a total, ring or gauge, as a meter
 *     PieChart      a donut filled to the centre
 *     FunnelChart   stages with their conversions
 *     StackedBarChart  horizontal parts per row: stacked, percent, diverging
 *     ScatterPlot   two measures, series by colour and shape, quadrants
 *     ComboChart    columns and lines on one axis; ParetoChart
 *
 *     RadarChart    profiles across a handful of attributes, one scale
 *     BoxPlot       distributions: quartiles, whiskers, outliers
 *     WaterfallChart   a level, its changes, and where it lands
 *     CalendarHeatmap  days in week columns, four states
 *     SunburstChart    a hierarchy of shares, as rings
 *
 *     NetworkGraph  nodes and edges, six layouts, deterministic
 *     SankeyChart   quantities flowing through stages
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Hand-drawn SVG over a pure kernel (_kernel: scales, encoding, formatting,
 * stacking), shared verbatim with the Svelte app. Cartesian plots draw into a
 * 0–1000 viewBox stretched with preserveAspectRatio="none"; strokes use
 * vector-effect: non-scaling-stroke, and points are zero-length round-capped
 * strokes, so nothing distorts. Axis labels are HTML positioned by percentage
 * (R6), thinned by a container query.
 *
 * Marks that animate carry data-mark; the motion runner finds them by it.
 */
export {};

components/charts/chart-frame/doc.ts

/**
 * ChartFrame — a figure for one chart.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     title         REQUIRED
 *     description?  a line under the title
 *     actions?      beside the title: a range toggle, an export
 *     footer?       under the chart: source, caveat, definition
 *     level?        render the title as a heading of this level
 *     children      the chart
 *
 * # Behaviour
 *
 * R1  A figure whose caption is the title and description.
 * R2  The title is not a heading unless `level` is given; give one when the
 *     chart is a section of the page a reader would navigate to.
 * R3  Actions wrap under the title when there is no room.
 * R4  It assumes nothing about what draws the chart.
 */
export {};

components/charts/chart-frame/chart-frame.tsx

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

import { Heading, type HeadingProps } from "@/components/typography/heading";
import { Text } from "@/components/typography/text";
import { cn } from "@/lib/utils/cn";
import styles from "./chart-frame.module.css";

export type ChartFrameProps = Omit<ComponentPropsWithRef<"figure">, "title"> & {
  /** What the chart shows. */
  title: ReactNode;
  description?: ReactNode;
  /** Beside the title: a range select, an export button. */
  actions?: ReactNode;
  /** Under the chart: the source, a caveat, a definition. */
  footer?: ReactNode;
  /** Render the title as a heading of this level, so it joins the outline. */
  level?: HeadingProps["level"];
};

/** A figure for one chart: title, description, actions, the chart, a footer.
 *  It assumes nothing about what draws the chart. */
export function ChartFrame({
  title,
  description,
  actions,
  footer,
  level,
  className,
  children,
  ...props
}: ChartFrameProps) {
  return (
    <figure {...props} className={cn(styles.root, className)}>
      <figcaption className={styles.header}>
        <div className={styles.heading}>
          {level ? (
            <Heading level={level} size="sm">
              {title}
            </Heading>
          ) : (
            <span className={styles.title}>{title}</span>
          )}
          {description ? (
            <Text size="sm" tone="muted">
              {description}
            </Text>
          ) : null}
        </div>
        {actions ? <div className={styles.actions}>{actions}</div> : null}
      </figcaption>
      {children}
      {footer ? <div className={styles.footer}>{footer}</div> : null}
    </figure>
  );
}

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

@layer primitive {
  .root {
    display: grid;
    min-width: 0;
    align-content: start;
    gap: var(--space-6);
    margin: 0;
  }
  .header {
    display: flex;
    flex-wrap: wrap;
    align-items: start;
    justify-content: space-between;
    gap: var(--space-5);
  }
  .heading {
    display: grid;
    min-width: 0;
    gap: var(--space-2);
  }
  .title {
    color: var(--ink);
    font-size: var(--text-15);
    font-weight: var(--weight-strong);
  }
  .actions {
    display: flex;
    align-items: center;
    gap: var(--space-3);
  }
  .footer {
    padding-top: var(--space-4);
    border-top: 1px solid var(--line);
    color: var(--ink-3);
    font-size: var(--text-12);
  }
}

components/charts/chart-legend/doc.ts

/**
 * ChartLegend — which colour, and which line style, is which.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     items    { key, label, color, line?, value?, note? }[]
 *     label?   names the list, default "Legend"
 *     layout?  "inline" (wraps) | "list" (one per row, values aligned)
 *
 * # Behaviour
 *
 * R1  A list; every item is named in text, so identity never rests on colour.
 * R2  An item with `line` shows a line in that style; otherwise a dot.
 * R3  `value` and `note` (a share) print at the row's end, in tabular figures.
 *
 * Charts with several series render their own legend; use this directly for
 * a legend shared by several charts.
 */
export {};

components/charts/chart-legend/chart-legend.tsx

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

import { shapePath, type LineStyle, type MarkShape } from "../_kernel/encode";
import { cn } from "@/lib/utils/cn";
import styles from "./chart-legend.module.css";

export type LegendItem = {
  key: string;
  label: string;
  /** A CSS colour: a series' `var(--chart-n)`. */
  color: string;
  /** Drawn as a line in this style; omitted draws a dot. */
  line?: LineStyle;
  /** Drawn as this shape, when the chart's marks carry one. */
  shape?: MarkShape;
  /** Printed at the end of the row: an exact value. */
  value?: ReactNode;
  /** Quieter text after the value: a share. */
  note?: ReactNode;
};

export type ChartLegendProps = {
  items: readonly LegendItem[];
  /** Names the list. */
  label?: string;
  /** Inline and wrapping, or one item per row with values aligned. */
  layout?: "inline" | "list";
  className?: string;
};

/** Which colour (and line style) is which. Identity never rests on colour
 *  alone: every item is named in text. */
export function ChartLegend({
  items,
  label = "Legend",
  layout = "inline",
  className,
}: ChartLegendProps) {
  return (
    <ul
      aria-label={label}
      data-layout={layout}
      className={cn(styles.root, className)}
    >
      {items.map((item) => (
        <li
          key={item.key}
          className={styles.item}
          style={{ "--series": item.color } as CSSProperties}
        >
          <svg
            className={styles.swatch}
            width={item.line ? 20 : 10}
            height="10"
            aria-hidden="true"
          >
            {item.line ? (
              <line x1="1" y1="5" x2="19" y2="5" data-line={item.line} />
            ) : item.shape ? (
              <path d={shapePath(item.shape, 3.6)} transform="translate(5 5)" />
            ) : (
              <circle cx="5" cy="5" r="4.5" />
            )}
          </svg>
          {item.label}
          {item.value !== undefined ? (
            <span className={styles.value}>
              {item.value}
              {item.note !== undefined ? (
                <span className={styles.note}> · {item.note}</span>
              ) : null}
            </span>
          ) : null}
        </li>
      ))}
    </ul>
  );
}

components/charts/_kernel/inspect.ts

import type { BoxStats } from "./box";
import type { CartesianDatum } from "./cartesian";
import { colorVar, seriesColor, type ChartSeries } from "./encode";
import { formatPercent } from "./format";
import type { RankedDatum } from "./ranked";
import type { WaterfallBar } from "./waterfall";
import { isValue, PLOT } from "./scale";

/* Inspecting a chart over labelled positions: which position the pointer or
   the keyboard is on, and what to say about it. Pure; the frameworks only
   draw the crosshair and the card. */

export type ReadoutRow = {
  key: string;
  label: string;
  color: string;
  /** The value as formatted, or "Not measured". */
  text: string;
  missing: boolean;
};

export type Readout = {
  title: string;
  rows: ReadoutRow[];
  /** The position's total, for stacked charts. */
  total: string | null;
  /** A line under the rows: what the numbers are, or where the item sits. */
  note: string | null;
  /** The whole readout as one sentence, for a screen reader. */
  sentence: string;
};

const MISSING = "Not measured";

/** A readout from its parts. The sentence says what the card shows, in the
 *  same order, unless the chart has a fuller one to say. */
export function describe(
  title: string,
  rows: ReadoutRow[],
  {
    total = null,
    note = null,
    sentence,
  }: { total?: string | null; note?: string | null; sentence?: string } = {},
): Readout {
  const parts = rows.map((row) => `${row.label} ${row.text}`);
  if (total !== null) parts.push(`total ${total}`);
  if (note !== null) parts.push(note.toLowerCase());
  return {
    title,
    rows,
    total,
    note,
    sentence: sentence ?? `${title}: ${parts.join(", ")}.`,
  };
}

/** A position's values, as the card shows them and a screen reader hears
 *  them. Values are the data's, never the drawing's: a percent-stacked chart
 *  reads the raw value and its share; a missing value says so. */
export function readout(
  datum: CartesianDatum,
  series: readonly ChartSeries[],
  {
    format,
    total = false,
    share = false,
    colors,
  }: {
    format: (value: number) => string;
    /** Add the position's total: for stacks. */
    total?: boolean;
    /** Follow each value with its share of the total: for percent stacks. */
    share?: boolean;
    /** Each series' colour, when the chart does not number them in order. */
    colors?: readonly string[];
  },
): Readout {
  const measured = series.flatMap((entry) => {
    const value = datum.values[entry.key];
    return isValue(value) ? [value] : [];
  });
  const sum = measured.reduce((a, b) => a + b, 0);
  const rows = series.map((entry, index): ReadoutRow => {
    const value = datum.values[entry.key];
    const color = colors?.[index] ?? seriesColor(entry, index);
    if (!isValue(value))
      return {
        key: entry.key,
        label: entry.label,
        color,
        text: MISSING,
        missing: true,
      };
    const text =
      share && sum > 0
        ? `${format(value)} (${formatPercent(value / sum)})`
        : format(value);
    return { key: entry.key, label: entry.label, color, text, missing: false };
  });
  return describe(datum.label, rows, {
    total: total && measured.length ? format(sum) : null,
  });
}

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

const KIND_COLOR = {
  total: colorVar("neutral"),
  increase: "var(--chart-pos)",
  decrease: "var(--chart-neg)",
  unavailable: colorVar("neutral"),
} as const;

/** A waterfall step: a level reads as itself; a change reads as the change
 *  and where it lands. Past an unmeasured change, levels are unknown. */
export function waterfallReadout(
  bar: WaterfallBar,
  format: (value: number) => string,
): Readout {
  const color = KIND_COLOR[bar.kind];
  const row = (
    key: string,
    label: string,
    value: number | null,
    sign = false,
  ) =>
    ({
      key,
      label,
      color,
      text:
        value === null
          ? key === "to"
            ? "Unknown"
            : MISSING
          : sign
            ? signed(format)(value)
            : format(value),
      missing: value === null,
    }) satisfies ReadoutRow;
  return describe(
    bar.label,
    bar.kind === "total"
      ? [row("to", "Level", bar.to)]
      : [
          row("value", "Change", bar.value, true),
          row("to", "Running total", bar.to),
        ],
  );
}

/** A box plot group: per series, the median and the middle half; the
 *  sentence adds the whiskers and outliers. */
export function boxReadout(
  title: string,
  series: readonly ChartSeries[],
  stats: readonly (BoxStats | null)[],
  format: (value: number) => string,
): Readout {
  const rows = series.map((entry, index): ReadoutRow => {
    const s = stats[index];
    return {
      key: entry.key,
      label: entry.label,
      color: seriesColor(entry, index),
      text: s
        ? `${format(s.median)} (${format(s.q1)}–${format(s.q3)})`
        : MISSING,
      missing: !s,
    };
  });
  const spoken = series.map((entry, index) => {
    const s = stats[index];
    if (!s) return `${entry.label} ${MISSING.toLowerCase()}`;
    const outliers = s.outliers?.length ?? 0;
    return (
      `${entry.label} median ${format(s.median)}, middle half ${format(s.q1)} to ${format(s.q3)}, ` +
      `whiskers ${format(s.min)} to ${format(s.max)}` +
      (outliers ? `, ${outliers} outlier${outliers === 1 ? "" : "s"}` : "")
    );
  });
  return describe(title, rows, {
    note: "Median (middle half)",
    sentence: `${title}: ${spoken.join("; ")}.`,
  });
}

/** A ranked item: its value and its place. */
export function rankedReadout(
  item: RankedDatum & { value: number },
  index: number,
  count: number,
  {
    title,
    color,
    format,
  }: { title: string; color: string; format: (value: number) => string },
): Readout {
  return describe(
    item.label,
    [
      {
        key: item.id,
        label: title,
        color,
        text: format(item.value),
        missing: false,
      },
    ],
    { note: `Rank ${index + 1} of ${count}` },
  );
}

/** The position nearest `x` (in plot units). */
export function nearestIndex(x: number, positions: readonly number[]) {
  let best = 0;
  for (let index = 1; index < positions.length; index++)
    if (Math.abs(positions[index] - x) < Math.abs(positions[best] - x))
      best = index;
  return best;
}

/** Where a key moves the inspection, or null when the key is not one of
 *  ours. From nothing, → starts at the first position and ← at the last. */
export function stepIndex(key: string, index: number | null, count: number) {
  if (count === 0) return null;
  const last = count - 1;
  switch (key) {
    case "ArrowRight":
      return index === null ? 0 : Math.min(last, index + 1);
    case "ArrowLeft":
      return index === null ? last : Math.max(0, index - 1);
    case "Home":
      return 0;
    case "End":
      return last;
    default:
      return null;
  }
}

/** The card sits beside the crosshair, on whichever side has room. */
export const readoutSide = (x: number) => (x > PLOT * 0.6 ? "left" : "right");

/** A marker where each series meets the crosshair. */
export type InspectMarker = { key: string; color: string; y: number };

/** The markers at `index` for series drawn as lines: `tops` is each series'
 *  y per position, null where it has no measurement. */
export const lineMarkers = (
  series: readonly {
    key: string;
    color: string;
    tops: readonly (number | null)[];
  }[],
  index: number,
): InspectMarker[] =>
  series.flatMap((entry) => {
    const y = entry.tops[index];
    return y === null || y === undefined
      ? []
      : [{ key: entry.key, color: entry.color, y }];
  });

components/charts/_shared/chart-inspector.tsx

"use client";

import {
  useId,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from "react";

import { VisuallyHidden } from "@/components/utility/visually-hidden";
import {
  nearestIndex,
  readoutSide,
  stepIndex,
  type InspectMarker,
  type Readout,
} from "../_kernel/inspect";
import { PLOT } from "../_kernel/scale";
import styles from "./chart.module.css";

const at = (units: number) => `${units / (PLOT / 100)}%`;

/** Reads a chart position by position. The pointer shows the nearest one;
 *  Tab in and the arrow keys step through them, Home and End jump, Escape
 *  lets go — and each step is announced. The card and crosshair are for the
 *  eye only: the announcement and the data table say the same. */
export function ChartInspector({
  label,
  positions,
  readout,
  markers,
  band,
}: {
  label: string;
  /** Each position's x, in plot units. */
  positions: readonly number[];
  readout: (index: number) => Readout;
  /** Where each series meets the crosshair. */
  markers?: (index: number) => InspectMarker[];
  /** Highlight a band this wide, in plot units, instead of a line. */
  band?: number;
}) {
  const hint = useId();
  const [active, setActive] = useState<number | null>(null);
  const [said, setSaid] = useState("");
  const index = active !== null && active < positions.length ? active : null;

  const point = (event: PointerEvent<HTMLDivElement>) => {
    const box = event.currentTarget.getBoundingClientRect();
    if (box.width === 0) return;
    setActive(
      nearestIndex(((event.clientX - box.left) / box.width) * PLOT, positions),
    );
  };
  const key = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape" && index !== null) {
      event.preventDefault();
      setActive(null);
      return;
    }
    const next = stepIndex(event.key, index, positions.length);
    if (next === null) return;
    event.preventDefault();
    setActive(next);
    setSaid(readout(next).sentence);
  };

  const shown = index === null ? null : readout(index);
  const x = index === null ? 0 : positions[index];
  return (
    <div
      className={styles.inspect}
      tabIndex={0}
      role="group"
      aria-label={`${label}: values by position`}
      aria-describedby={hint}
      onPointerMove={point}
      onPointerDown={point}
      onPointerLeave={() => setActive(null)}
      onBlur={() => setActive(null)}
      onKeyDown={key}
    >
      <VisuallyHidden id={hint}>
        Use the left and right arrow keys to read each position.
      </VisuallyHidden>
      <VisuallyHidden role="status">{said}</VisuallyHidden>
      {shown && index !== null ? (
        <div aria-hidden="true">
          {band ? (
            <span
              className={styles.inspectBand}
              style={{ left: at(x - band / 2), width: at(band) }}
            />
          ) : (
            <span className={styles.crosshair} style={{ left: at(x) }} />
          )}
          {markers?.(index).map((marker) => (
            <span
              key={marker.key}
              className={styles.marker}
              style={
                {
                  left: at(x),
                  top: at(marker.y),
                  "--series": marker.color,
                } as CSSProperties
              }
            />
          ))}
          <div
            className={styles.readout}
            data-side={readoutSide(x)}
            style={{ "--x": at(x) } as CSSProperties}
          >
            <p className={styles.readoutTitle}>{shown.title}</p>
            {shown.rows.map((row) => (
              <p
                key={row.key}
                className={styles.readoutRow}
                data-missing={row.missing || undefined}
                style={{ "--series": row.color } as CSSProperties}
              >
                <span className={styles.readoutLabel}>{row.label}</span>
                <span className={styles.readoutValue}>{row.text}</span>
              </p>
            ))}
            {shown.total !== null ? (
              <p className={styles.readoutRow} data-total>
                <span className={styles.readoutLabel}>Total</span>
                <span className={styles.readoutValue}>{shown.total}</span>
              </p>
            ) : null}
            {shown.note !== null ? (
              <p className={styles.readoutNote}>{shown.note}</p>
            ) : null}
          </div>
        </div>
      ) : null}
    </div>
  );
}

components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  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/line-chart/line-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 type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatPercentTick, formatTick } from "../_kernel/format";
import { lineMarkers, readout } from "../_kernel/inspect";
import { linesGeometry, linesTarget, type LinesStack } from "../_kernel/lines";
import type { Curve } from "../_kernel/curves";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
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";

type Shared = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  series: readonly ChartSeries[];
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** The plot's height, as a CSS length. */
  height?: string;
  /** Show the legend; defaults to when there is more than one series. */
  legend?: boolean;
  /** Mark each measurement; defaults to when there are 24 or fewer. A lone
   *  measurement between gaps is always marked, or it would not show. */
  points?: boolean;
  /** How points join: straight, a smooth curve that never overshoots the
   *  data, or steps (a value that holds until it changes). */
  curve?: Curve;
  /** Default: each series wipes in from the left, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

export type LineChartProps = Shared & {
  /** Keep zero on the axis (default). Turn off only when the variation, not
   *  the size, is the point — and say so. */
  zero?: boolean;
};

export type AreaChartProps = Shared & {
  /** Stack series into a total, or (percent) into each position's shares.
   *  Needs non-negative values. */
  stacked?: boolean | "percent";
};

function Lines({
  kind,
  stacked = false,
  zero = true,
  label,
  data,
  series,
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  legend = series.length > 1,
  points,
  animation,
  className,
  curve = "linear",
  inspect = true,
}: Shared & {
  kind: "line" | "area";
  stacked?: boolean | "percent";
  zero?: boolean;
}) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "wipe",
    axis: "x",
  });
  const mode: LinesStack =
    stacked === "percent" ? "percent" : stacked ? "stacked" : "none";
  const { target, step, measured } = linesTarget(data, series, {
    mode,
    zero: zero || kind === "area",
  });
  const shown = useTweened(target, update);

  if (data.length === 0 || series.length === 0) {
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );
  }
  const plot = linesGeometry(shown, data, series, {
    area: kind === "area",
    stacked: mode !== "none",
    // In percent the top series is always 100%: marking it says nothing.
    points: points ?? (data.length <= 24 && mode !== "percent"),
    step,
    format: mode === "percent" ? formatPercentTick : tickFormat,
    curve,
  });

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {legend ? <SeriesLegend series={series} lines /> : null}
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={plot.ticks}
          height={height}
          zeroAt={plot.zeroAt}
          xLabels={plot.xLabels}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={plot.positions}
                readout={(index) =>
                  readout(data[index], series, {
                    format: formatValue,
                    total: mode !== "none",
                    share: mode === "percent",
                  })
                }
                markers={(index) => lineMarkers(plot.series, index)}
              />
            ) : null
          }
        >
          {plot.series.map((entry) => (
            <g
              key={entry.key}
              data-mark
              style={{ "--series": entry.color } as CSSProperties}
            >
              {entry.area !== null ? (
                <path
                  className={chart.area}
                  data-stacked={mode !== "none" || undefined}
                  d={entry.area}
                />
              ) : null}
              <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>
      )}
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

/** Values over ordered positions, one line per series. A missing value
 *  breaks the line rather than being joined across or treated as zero. */
export function LineChart(props: LineChartProps) {
  return <Lines {...props} kind="line" />;
}

/** A line chart whose area is filled to zero — or, stacked, to the series
 *  below, so the top edge is the total. */
export function AreaChart(props: AreaChartProps) {
  return <Lines {...props} kind="area" />;
}

AreaChart

stacked · single

Requests by product

Stacked: the top edge is the total.

  • API
  • Storage
  • Compute
Use the left and right arrow keys to read each position.
View data for Weekly requests by product, thousands
Weekly requests by product, thousands
LabelAPIStorageCompute
W2742k18k9k
W2845k19k11k
W2944k21k10k
W3051k21k14k
W3155k22k13k
W3253k24k16k
W3358k25k18k
W3461k25k17k
W3566k27k21k
W3664k29k24k
W3770k30k23k
W3874k31k27k
API requests

One series, filled to zero.

Use the left and right arrow keys to read each position.
View data for Weekly API requests, thousands
Weekly API requests, thousands
LabelAPI
W2742k
W2845k
W2944k
W3051k
W3155k
W3253k
W3358k
W3461k
W3566k
W3664k
W3770k
W3874k
Sourcecomponents/charts/line-chart/doc.ts · components/charts/line-chart/line-chart.tsx

components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  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/line-chart/line-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 type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatPercentTick, formatTick } from "../_kernel/format";
import { lineMarkers, readout } from "../_kernel/inspect";
import { linesGeometry, linesTarget, type LinesStack } from "../_kernel/lines";
import type { Curve } from "../_kernel/curves";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
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";

type Shared = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  series: readonly ChartSeries[];
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** The plot's height, as a CSS length. */
  height?: string;
  /** Show the legend; defaults to when there is more than one series. */
  legend?: boolean;
  /** Mark each measurement; defaults to when there are 24 or fewer. A lone
   *  measurement between gaps is always marked, or it would not show. */
  points?: boolean;
  /** How points join: straight, a smooth curve that never overshoots the
   *  data, or steps (a value that holds until it changes). */
  curve?: Curve;
  /** Default: each series wipes in from the left, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

export type LineChartProps = Shared & {
  /** Keep zero on the axis (default). Turn off only when the variation, not
   *  the size, is the point — and say so. */
  zero?: boolean;
};

export type AreaChartProps = Shared & {
  /** Stack series into a total, or (percent) into each position's shares.
   *  Needs non-negative values. */
  stacked?: boolean | "percent";
};

function Lines({
  kind,
  stacked = false,
  zero = true,
  label,
  data,
  series,
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  legend = series.length > 1,
  points,
  animation,
  className,
  curve = "linear",
  inspect = true,
}: Shared & {
  kind: "line" | "area";
  stacked?: boolean | "percent";
  zero?: boolean;
}) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "wipe",
    axis: "x",
  });
  const mode: LinesStack =
    stacked === "percent" ? "percent" : stacked ? "stacked" : "none";
  const { target, step, measured } = linesTarget(data, series, {
    mode,
    zero: zero || kind === "area",
  });
  const shown = useTweened(target, update);

  if (data.length === 0 || series.length === 0) {
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );
  }
  const plot = linesGeometry(shown, data, series, {
    area: kind === "area",
    stacked: mode !== "none",
    // In percent the top series is always 100%: marking it says nothing.
    points: points ?? (data.length <= 24 && mode !== "percent"),
    step,
    format: mode === "percent" ? formatPercentTick : tickFormat,
    curve,
  });

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {legend ? <SeriesLegend series={series} lines /> : null}
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={plot.ticks}
          height={height}
          zeroAt={plot.zeroAt}
          xLabels={plot.xLabels}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={plot.positions}
                readout={(index) =>
                  readout(data[index], series, {
                    format: formatValue,
                    total: mode !== "none",
                    share: mode === "percent",
                  })
                }
                markers={(index) => lineMarkers(plot.series, index)}
              />
            ) : null
          }
        >
          {plot.series.map((entry) => (
            <g
              key={entry.key}
              data-mark
              style={{ "--series": entry.color } as CSSProperties}
            >
              {entry.area !== null ? (
                <path
                  className={chart.area}
                  data-stacked={mode !== "none" || undefined}
                  d={entry.area}
                />
              ) : null}
              <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>
      )}
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

/** Values over ordered positions, one line per series. A missing value
 *  breaks the line rather than being joined across or treated as zero. */
export function LineChart(props: LineChartProps) {
  return <Lines {...props} kind="line" />;
}

/** A line chart whose area is filled to zero — or, stacked, to the series
 *  below, so the top edge is the total. */
export function AreaChart(props: AreaChartProps) {
  return <Lines {...props} kind="area" />;
}

ColumnChart

grouped · stacked · negative · missing

Signups and activations

Grouped.

  • Signups
  • Activated
Use the left and right arrow keys to read each position.
View data for Signups and activations by month
Signups and activations by month
LabelSignupsActivated
Apr420251
May468290
Jun455268
Jul512331
Aug540362
Sep601410
Recurring revenue by plan

Stacked.

  • Starter
  • Team
  • Enterprise
Use the left and right arrow keys to read each position.
View data for Monthly recurring revenue by plan
Monthly recurring revenue by plan
LabelStarterTeamEnterprise
Apr$8,200$21,400$34,000
May$8,450$22,800$34,000
Jun$8,610$23,900$41,500
Jul$8,900$25,100$41,500
Aug$9,120$26,700$43,800
Sep$9,480$28,300$49,200
Net member change

Below zero grows downward. W36 was not measured.

Use the left and right arrow keys to read each position.
View data for Net member change by week
Net member change by week
LabelNet members
W3312
W347
W35-4
W36Unavailable
W379
W38-11
W393

Stacking needs parts of a whole. Negative values cannot stack, so a stacked chart leaves them out of the picture; the data table still has them.

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

components/charts/column-chart/doc.ts

/**
 * ColumnChart — vertical columns over labelled positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, data, series, formatValue?, formatTick?, height?, legend?  as
 *                  LineChart
 *     layout?      "grouped" (default) | "stacked" | "percent"
 *     animation?   default: columns grow from zero, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  Columns measure from zero; the axis always includes it. Negative
 *     columns hang below a stronger zero line and grow downward.
 * R2  A null draws no column and leaves its slot empty.
 * R3  Stacked columns need non-negative values: others are left out of the
 *     picture and kept in the table. The stack's top is the total.
 * R4  Percent draws each column's parts as shares of its total, on a
 *     0–100% axis; the table keeps the raw values.
 * R5  New data tweens: columns, stacks, and the axis move together; a column
 *     for a newly measured value grows from zero.
 * R6  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/column-chart/column-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 {
  columnGeometry,
  columnTarget,
  type ColumnLayout,
} from "../_kernel/column";
import type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatPercentTick, formatTick } from "../_kernel/format";
import { readout } from "../_kernel/inspect";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
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";

export type ColumnChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  series: readonly ChartSeries[];
  /** Series side by side, stacked into a total, or stacked as each
   *  column's shares (percent). Stacking needs non-negative values; others
   *  are left out of the picture, not zeroed. */
  layout?: ColumnLayout;
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** The plot's height, as a CSS length. */
  height?: string;
  /** Show the legend; defaults to when there is more than one series. */
  legend?: boolean;
  /** Default: columns grow from zero, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

/** Vertical columns over labelled positions, grouped or stacked. */
export function ColumnChart({
  label,
  data,
  series,
  layout = "grouped",
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  legend = series.length > 1,
  animation,
  inspect = true,
  className,
}: ColumnChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "y",
  });
  const stacked = layout !== "grouped";
  const percent = layout === "percent";
  const { target, step, measured } = columnTarget(data, series, layout);
  const shown = useTweened(target, update);

  if (data.length === 0 || series.length === 0) {
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );
  }
  const plot = columnGeometry(
    shown,
    data,
    series,
    stacked,
    step,
    percent ? formatPercentTick : tickFormat,
  );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {legend ? <SeriesLegend series={series} lines={false} /> : null}
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={plot.ticks}
          height={height}
          zeroAt={plot.zeroAt}
          xLabels={plot.xLabels}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={plot.positions}
                band={plot.bandWidth}
                readout={(index) =>
                  readout(data[index], series, {
                    format: formatValue,
                    total: stacked,
                    share: percent,
                  })
                }
              />
            ) : null
          }
        >
          {plot.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>
          ))}
        </CartesianPlot>
      ) : (
        <p className={chart.empty}>Measurements unavailable.</p>
      )}
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

Curves

linear · smooth · step

Linear

The default.

Use the left and right arrow keys to read each position.
View data for Active workspaces, linear
Active workspaces, linear
LabelThis year
Feb1,398
Mar1,467
Apr1,520
May1,604
Jun1,688
Jul1,731
Aug1,840
Sep1,962
Smooth

Monotone: never overshoots a point.

Use the left and right arrow keys to read each position.
View data for Active workspaces, smooth
Active workspaces, smooth
LabelThis year
Feb1,398
Mar1,467
Apr1,520
May1,604
Jun1,688
Jul1,731
Aug1,840
Sep1,962
Step

A value that holds until it changes.

Use the left and right arrow keys to read each position.
View data for Active workspaces, stepped
Active workspaces, stepped
LabelThis year
Feb1,398
Mar1,467
Apr1,520
May1,604
Jun1,688
Jul1,731
Aug1,840
Sep1,962

Smooth never invents a peak. The curve is monotone: between two points it stays between their values, so it cannot suggest a high or low the data does not have.

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

components/charts/line-chart/doc.ts

/**
 * LineChart / AreaChart — values over ordered positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, the chart's name
 *     data         { label, values: { [series key]: number | null } }[]
 *     series       { key, label, color?, line? }[]
 *     formatValue? exact values (table, legend) — default: every digit
 *     formatTick?  axis ticks — default: compact (1.2K)
 *     height?      the plot's height
 *     legend?      default: when there is more than one series
 *     points?      mark each measurement; default when 24 or fewer
 *     animation?   default: each series wipes in, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *     LineChart    zero? — keep zero on the axis, default true
 *     curve?       "linear" (default) | "smooth" | "step"
 *     AreaChart    stacked? — true into a total, "percent" into shares
 *
 * # Behaviour
 *
 * R1  A null breaks the line; the stretches either side are separate. A lone
 *     measurement between gaps is always marked, or it would not show.
 * R2  The axis includes zero unless `zero` is false. An area is always filled
 *     to zero (or, stacked, to the series below), so it always includes zero.
 * R3  Stacked areas need non-negative values: others are left out of the
 *     picture and kept in the table.
 * R4  One position is drawn at the centre; a constant series is a flat line.
 * R5  The first and last x labels align to the plot's edges; the rest are
 *     centred on their position and thinned to fit.
 * R6  New data tweens: lines, areas, and the axis move to it together.
 * R7  "smooth" is monotone: between two points it never goes above the
 *     higher or below the lower, so it cannot invent a peak. "step" holds
 *     each value until the next.
 * R8  Percent stacking draws each position's parts as shares of its total,
 *     on a 0–100% axis; the table keeps the raw values.
 * R9  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/line-chart/line-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 type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatPercentTick, formatTick } from "../_kernel/format";
import { lineMarkers, readout } from "../_kernel/inspect";
import { linesGeometry, linesTarget, type LinesStack } from "../_kernel/lines";
import type { Curve } from "../_kernel/curves";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
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";

type Shared = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  series: readonly ChartSeries[];
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** The plot's height, as a CSS length. */
  height?: string;
  /** Show the legend; defaults to when there is more than one series. */
  legend?: boolean;
  /** Mark each measurement; defaults to when there are 24 or fewer. A lone
   *  measurement between gaps is always marked, or it would not show. */
  points?: boolean;
  /** How points join: straight, a smooth curve that never overshoots the
   *  data, or steps (a value that holds until it changes). */
  curve?: Curve;
  /** Default: each series wipes in from the left, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

export type LineChartProps = Shared & {
  /** Keep zero on the axis (default). Turn off only when the variation, not
   *  the size, is the point — and say so. */
  zero?: boolean;
};

export type AreaChartProps = Shared & {
  /** Stack series into a total, or (percent) into each position's shares.
   *  Needs non-negative values. */
  stacked?: boolean | "percent";
};

function Lines({
  kind,
  stacked = false,
  zero = true,
  label,
  data,
  series,
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  legend = series.length > 1,
  points,
  animation,
  className,
  curve = "linear",
  inspect = true,
}: Shared & {
  kind: "line" | "area";
  stacked?: boolean | "percent";
  zero?: boolean;
}) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "wipe",
    axis: "x",
  });
  const mode: LinesStack =
    stacked === "percent" ? "percent" : stacked ? "stacked" : "none";
  const { target, step, measured } = linesTarget(data, series, {
    mode,
    zero: zero || kind === "area",
  });
  const shown = useTweened(target, update);

  if (data.length === 0 || series.length === 0) {
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );
  }
  const plot = linesGeometry(shown, data, series, {
    area: kind === "area",
    stacked: mode !== "none",
    // In percent the top series is always 100%: marking it says nothing.
    points: points ?? (data.length <= 24 && mode !== "percent"),
    step,
    format: mode === "percent" ? formatPercentTick : tickFormat,
    curve,
  });

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {legend ? <SeriesLegend series={series} lines /> : null}
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={plot.ticks}
          height={height}
          zeroAt={plot.zeroAt}
          xLabels={plot.xLabels}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={plot.positions}
                readout={(index) =>
                  readout(data[index], series, {
                    format: formatValue,
                    total: mode !== "none",
                    share: mode === "percent",
                  })
                }
                markers={(index) => lineMarkers(plot.series, index)}
              />
            ) : null
          }
        >
          {plot.series.map((entry) => (
            <g
              key={entry.key}
              data-mark
              style={{ "--series": entry.color } as CSSProperties}
            >
              {entry.area !== null ? (
                <path
                  className={chart.area}
                  data-stacked={mode !== "none" || undefined}
                  d={entry.area}
                />
              ) : null}
              <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>
      )}
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

/** Values over ordered positions, one line per series. A missing value
 *  breaks the line rather than being joined across or treated as zero. */
export function LineChart(props: LineChartProps) {
  return <Lines {...props} kind="line" />;
}

/** A line chart whose area is filled to zero — or, stacked, to the series
 *  below, so the top edge is the total. */
export function AreaChart(props: AreaChartProps) {
  return <Lines {...props} kind="area" />;
}

Percent layouts

columns · area

Revenue mix by plan

Percent columns: each month's shares.

  • Starter
  • Team
  • Enterprise
Use the left and right arrow keys to read each position.
View data for Share of recurring revenue by plan
Share of recurring revenue by plan
LabelStarterTeamEnterprise
Apr$8,200$21,400$34,000
May$8,450$22,800$34,000
Jun$8,610$23,900$41,500
Jul$8,900$25,100$41,500
Aug$9,120$26,700$43,800
Sep$9,480$28,300$49,200
Request mix by product

Percent area, smoothed.

  • API
  • Storage
  • Compute
Use the left and right arrow keys to read each position.
View data for Share of weekly requests by product
Share of weekly requests by product
LabelAPIStorageCompute
W2742k18k9k
W2845k19k11k
W2944k21k10k
W3051k21k14k
W3155k22k13k
W3253k24k16k
W3358k25k18k
W3461k25k17k
W3566k27k21k
W3664k29k24k
W3770k30k23k
W3874k31k27k

Shares, not sizes. Each position fills to 100%, so the mix is comparable when totals differ. The tables keep the raw values.

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

components/charts/column-chart/doc.ts

/**
 * ColumnChart — vertical columns over labelled positions.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label, data, series, formatValue?, formatTick?, height?, legend?  as
 *                  LineChart
 *     layout?      "grouped" (default) | "stacked" | "percent"
 *     animation?   default: columns grow from zero, when scrolled into view
 *     inspect?     read values by pointer or keyboard; default true
 *
 * # Behaviour
 *
 * R1  Columns measure from zero; the axis always includes it. Negative
 *     columns hang below a stronger zero line and grow downward.
 * R2  A null draws no column and leaves its slot empty.
 * R3  Stacked columns need non-negative values: others are left out of the
 *     picture and kept in the table. The stack's top is the total.
 * R4  Percent draws each column's parts as shares of its total, on a
 *     0–100% axis; the table keeps the raw values.
 * R5  New data tweens: columns, stacks, and the axis move together; a column
 *     for a newly measured value grows from zero.
 * R6  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/column-chart/column-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 {
  columnGeometry,
  columnTarget,
  type ColumnLayout,
} from "../_kernel/column";
import type { ChartSeries } from "../_kernel/encode";
import { formatExact, formatPercentTick, formatTick } from "../_kernel/format";
import { readout } from "../_kernel/inspect";
import { CartesianPlot } from "../_shared/cartesian-plot";
import { ChartInspector } from "../_shared/chart-inspector";
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";

export type ColumnChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly CartesianDatum[];
  series: readonly ChartSeries[];
  /** Series side by side, stacked into a total, or stacked as each
   *  column's shares (percent). Stacking needs non-negative values; others
   *  are left out of the picture, not zeroed. */
  layout?: ColumnLayout;
  formatValue?: (value: number) => string;
  formatTick?: (value: number) => string;
  /** The plot's height, as a CSS length. */
  height?: string;
  /** Show the legend; defaults to when there is more than one series. */
  legend?: boolean;
  /** Default: columns grow from zero, when scrolled into view. */
  animation?: AnimationProp;
  /** Read values position by position, by pointer or keyboard (default). */
  inspect?: boolean;
  className?: string;
};

/** Vertical columns over labelled positions, grouped or stacked. */
export function ColumnChart({
  label,
  data,
  series,
  layout = "grouped",
  formatValue = formatExact,
  formatTick: tickFormat = formatTick,
  height,
  legend = series.length > 1,
  animation,
  inspect = true,
  className,
}: ColumnChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "y",
  });
  const stacked = layout !== "grouped";
  const percent = layout === "percent";
  const { target, step, measured } = columnTarget(data, series, layout);
  const shown = useTweened(target, update);

  if (data.length === 0 || series.length === 0) {
    return (
      <div className={cn(chart.root, className)}>
        <p className={chart.empty}>No data to display.</p>
      </div>
    );
  }
  const plot = columnGeometry(
    shown,
    data,
    series,
    stacked,
    step,
    percent ? formatPercentTick : tickFormat,
  );

  return (
    <div {...rootProps} className={cn(chart.root, className)}>
      {legend ? <SeriesLegend series={series} lines={false} /> : null}
      {measured ? (
        <CartesianPlot
          label={label}
          ticks={plot.ticks}
          height={height}
          zeroAt={plot.zeroAt}
          xLabels={plot.xLabels}
          overlay={
            inspect ? (
              <ChartInspector
                label={label}
                positions={plot.positions}
                band={plot.bandWidth}
                readout={(index) =>
                  readout(data[index], series, {
                    format: formatValue,
                    total: stacked,
                    share: percent,
                  })
                }
              />
            ) : null
          }
        >
          {plot.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>
          ))}
        </CartesianPlot>
      ) : (
        <p className={chart.empty}>Measurements unavailable.</p>
      )}
      <SeriesTable
        label={label}
        data={data}
        series={series}
        formatValue={formatValue}
      />
    </div>
  );
}

BarChart

ranked · zero · unavailable · quota

Seats by team

Support has zero seats; Finance was not reported.

  • Engineering48
  • Design14
  • Sales22
  • Support0
  • FinanceUnavailable
Storage against quota

A fixed maximum: a full bar is the 1 TB quota.

  • Northstar871 GB
  • Atlas412 GB
  • Juniper96 GB

The values are printed, so a bar chart is its own data table. Zero is a measurement and draws an empty track; unavailable says so.

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

components/charts/bar-chart/doc.ts

/**
 * BarChart — labelled horizontal bars with their values printed.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, names the list
 *     data         { key?, label, value: number | null }[]
 *     color?       a categorical slot, default 1
 *     max?         the value a full bar stands for; default the largest
 *     formatValue? default: every digit
 *     animation?   default: bars grow from the left, when scrolled into view
 *
 * # Behaviour
 *
 * R1  A list of rows: label, bar, value. The values are text, so the chart is
 *     its own data table.
 * R2  Bars measure non-negative amounts from zero. Zero draws an empty track;
 *     null, negative, or invalid reads "Unavailable" and draws no bar.
 * R3  Rows keep the caller's order: sort by value when the ranking is the
 *     point.
 * R4  With `max`, bars are shares of it (a quota); values over it fill the
 *     track and still print exactly.
 */
export {};

components/charts/bar-chart/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 { colorVar, type ChartColor } from "../_kernel/encode";
import { formatExact } from "../_kernel/format";
import {
  barKey,
  barShare,
  barTarget,
  measuredBar,
  type BarDatum,
} from "../_kernel/bar";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./bar-chart.module.css";

export type BarChartProps = {
  /** Names the chart. */
  label: string;
  data: readonly BarDatum[];
  color?: ChartColor;
  /** The value a full bar stands for; defaults to the largest. Set it to
   *  compare charts, or for a quota. */
  max?: number;
  formatValue?: (value: number) => string;
  /** Default: bars grow from zero, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
};

/** Labelled horizontal bars with their values printed: a ranking, a
 *  breakdown. The values are text, so the chart is its own data table. */
export function BarChart({
  label,
  data,
  color = 1,
  max,
  formatValue = formatExact,
  animation,
  className,
}: BarChartProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "grow",
    axis: "x",
  });
  const shown = useTweened(barTarget(data, max), update);

  return (
    <div
      {...rootProps}
      className={cn(chart.root, className)}
      style={{ "--series": colorVar(color) } as CSSProperties}
    >
      {data.length === 0 ? (
        <p className={chart.empty}>No data to display.</p>
      ) : (
        <ul aria-label={label} className={styles.list}>
          {data.map((item, index) => {
            const key = barKey(item, index);
            return (
              <li key={key} className={styles.row}>
                <span className={styles.label}>{item.label}</span>
                <span className={styles.track} aria-hidden="true">
                  {measuredBar(item.value) ? (
                    <span
                      data-mark
                      className={styles.fill}
                      style={{ width: `${barShare(shown, key) * 100}%` }}
                    />
                  ) : null}
                </span>
                <span
                  className={cn(
                    styles.value,
                    !measuredBar(item.value) && styles.unavailable,
                  )}
                >
                  {measuredBar(item.value)
                    ? formatValue(item.value)
                    : "Unavailable"}
                </span>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

export type { BarDatum };

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

@layer primitive {
  /* Rows share the list's columns (subgrid), so every track starts and ends
     at the same place whatever its label or value. */
  .list {
    display: grid;
    grid-template-columns: minmax(5rem, 1fr) minmax(4rem, 3fr) auto;
    gap: var(--space-4) var(--space-5);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .row {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: center;
    font-size: var(--text-12);
  }
  .label {
    min-width: 0;
    color: var(--ink-2);
    overflow-wrap: anywhere;
  }
  .track {
    height: 0.625rem;
    overflow: hidden;
    border-radius: var(--radius-1);
    background: var(--chart-absent, var(--surface-hover));
  }
  .fill {
    display: block;
    height: 100%;
    border-radius: inherit;
    background: var(--series);
    transform-origin: 0 50%;
  }
  .value {
    min-width: 4ch;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
    text-align: end;
  }
  .unavailable {
    color: var(--ink-3);
  }
}

Sparkline

stat cards · table cells · gaps

Active users68+119% in 30 days
Error rate2.3%−45% in 30 days
Deploys147Two days not recorded
Workspaces
WorkspaceRequestsLast 30 days
Northstar1.4M
Atlas812K
Juniper96K

A sparkline shows shape, not size. Its scale runs from the lowest value to the highest, so always pair it with the number it summarises. Its accessible name carries the first, last, lowest, and highest values.

Sourcecomponents/charts/sparkline/doc.ts · components/charts/sparkline/sparkline.tsx · components/charts/sparkline/sparkline.module.css

components/charts/sparkline/doc.ts

/**
 * Sparkline — a trend in the space of a word.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label        REQUIRED, what the values are
 *     values       (number | null)[], in order
 *     color?       a categorical slot, default 1
 *     area?        fill under the line
 *     formatValue? for the summary
 *     animation?   default: wipes in, when scrolled into view
 *
 * # Behaviour
 *
 * R1  An image whose name is the label plus a summary: first, last, lowest,
 *     and highest values — or "no measurements".
 * R2  The scale runs from the lowest value to the highest, not from zero: a
 *     sparkline shows shape. Pair it with the number it summarises.
 * R3  A null is a gap. The last measurement is marked with a dot.
 * R4  Sized by its container, or by --sparkline-w and --sparkline-h.
 */
export {};

components/charts/sparkline/sparkline.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 } from "../_kernel/format";
import { PLOT } from "../_kernel/scale";
import {
  sparklineGeometry,
  sparklineSummary,
  sparklineTarget,
} from "../_kernel/sparkline";
import chart from "../_shared/chart.module.css";
import { useChartMotion } from "../_shared/use-chart-motion";
import styles from "./sparkline.module.css";

export type SparklineProps = {
  /** What the values are; the summary is added to it. */
  label: string;
  /** In order. Null is a gap. */
  values: readonly (number | null)[];
  color?: ChartColor;
  /** Fill under the line. */
  area?: boolean;
  formatValue?: (value: number) => string;
  /** Default: wipes in from the left, when scrolled into view. */
  animation?: AnimationProp;
  className?: string;
  style?: CSSProperties;
};

/** A trend in the space of a word: no axes, no labels. Its scale runs from
 *  the lowest value to the highest, not from zero — it shows shape, so pair
 *  it with the number it summarises. */
export function Sparkline({
  label,
  values,
  color = 1,
  area = false,
  formatValue = formatExact,
  animation,
  className,
  style,
}: SparklineProps) {
  const { rootProps, update } = useChartMotion(animation, {
    enter: "wipe",
    axis: "x",
  });
  const shown = useTweened(sparklineTarget(values), update);
  const { line, fill, end } = sparklineGeometry(shown, values);

  return (
    <span
      {...rootProps}
      className={cn(chart.root, styles.root, className)}
      style={{ "--series": colorVar(color), ...style } as CSSProperties}
    >
      <svg
        className={styles.svg}
        viewBox={`0 0 ${PLOT} ${PLOT}`}
        preserveAspectRatio="none"
        role="img"
        aria-label={`${label}: ${sparklineSummary(values, formatValue)}`}
      >
        <g data-mark>
          {area ? <path className={styles.area} d={fill} /> : null}
          <path className={styles.line} d={line} />
          {end ? <path className={styles.end} d={end} /> : null}
        </g>
      </svg>
    </span>
  );
}

components/charts/sparkline/sparkline.module.css

@layer primitive {
  .root {
    display: inline-block;
    width: var(--sparkline-w, 100%);
    height: var(--sparkline-h, 2rem);
    vertical-align: middle;
  }
  .svg {
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  .line {
    fill: none;
    stroke: var(--series);
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-width: 1.5;
    vector-effect: non-scaling-stroke;
  }
  .area {
    fill: color-mix(in oklab, var(--series) 14%, transparent);
  }
  .end {
    stroke: var(--series);
    stroke-linecap: round;
    stroke-width: 5;
    vector-effect: non-scaling-stroke;
  }
}

DonutChart

share of a whole · folds past four

Accounts by plan
  • Free1,840 · 63.9%
  • Starter612 · 21.2%
  • Team388 · 13.5%
  • Enterprise41 · 1.4%
Storage by type

Seven parts: the three smallest fold into Other.

  • Video412 GB · 47.3%
  • Images238 GB · 27.3%
  • Documents121 GB · 13.9%
  • Archives64 GB · 7.3%
  • Other (3)36 GB · 4.1%

Four hues, then Other. The palette never cycles, so past four parts the rest fold into a neutral Other. The legend carries every exact value and share.

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

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} />;
}

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

@layer primitive {
  .layout {
    display: grid;
    grid-template-columns: minmax(7rem, 11rem) minmax(12rem, 1fr);
    align-items: center;
    gap: var(--space-8);
  }
  @container (max-width: 26rem) {
    .layout {
      grid-template-columns: 1fr;
      justify-items: center;
    }
  }
  .wrap {
    container-type: inline-size;
  }
  .ring {
    position: relative;
    width: 100%;
    max-width: 11rem;
    aspect-ratio: 1;
  }
  .svg {
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
    transform: rotate(-90deg);
  }
  .track {
    fill: none;
    stroke: var(--chart-absent, var(--surface-hover));
  }
  .arc {
    fill: none;
    stroke: var(--series);
    stroke-dasharray: 1 1;
  }
  .centre {
    position: absolute;
    inset: 0;
    display: grid;
    align-content: center;
    justify-items: center;
    gap: var(--space-1);
    text-align: center;
  }
  .total {
    color: var(--ink);
    font-size: var(--text-18, 18px);
    font-variant-numeric: tabular-nums;
    font-weight: var(--weight-strong);
    line-height: 1.1;
  }
  .caption {
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .legend {
    width: 100%;
  }
}

Motion

presets · custom spring · replay · new data

Columns
  • Signups
  • Activated
Use the left and right arrow keys to read each position.
View data for Signups and activations by month
Signups and activations by month
LabelSignupsActivated
Apr420251
May468290
Jun455268
Jul512331
Aug540362
Sep601410
Lines
  • This year
  • Last year
Use the left and right arrow keys to read each position.
View data for Active workspaces by month
Active workspaces by month
LabelThis yearLast year
Oct1,180840
Nov1,242876
Dec1,204910
Jan1,310902
Feb1,398954
Mar1,4671,003
Apr1,5201,041
May1,6041,066
Jun1,6881,102
Jul1,7311,150
Aug1,8401,162
Sep1,9621,175
Bars
  • Engineering48
  • Design14
  • Sales22
  • Support0
  • FinanceUnavailable
Donut
  • Free1,840 · 63.9%
  • Starter612 · 21.2%
  • Team388 · 13.5%
  • Enterprise41 · 1.4%

An animation is data: keyframes and timing, run by motion’s framework-free animate(). The same spec behaves the same in the React and Svelte apps.

Enter and update are separate. enter animates the marks in once (on mount, or when first scrolled into view); update is how they move to new data.

Reduced motion always wins: marks show at once and new data lands without tweening. Nothing is hidden when scripts do not run.

Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · components/charts/_shared/chart.module.css

lib/motion/doc.ts

/**
 * motion — animation as data, run the same way in both apps.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Timing       tween { duration, ease, delay } | spring { visualDuration,
 *                  bounce, delay } — seconds
 *     EnterSpec    { keyframes, timing?, stagger? } — each property from its
 *                  first keyframe to its last
 *     EnterPreset  fade | rise | grow | wipe | trace | pop
 *     animation    preset | { enter?, update?, trigger? } | false
 *                    enter    preset | EnterSpec | false
 *                    update   Timing | false — how marks move to new data
 *                    trigger  "visible" (default) | "mount"
 *
 * # Behaviour
 *
 * R1  A spec is plain data: no functions, no framework. The same spec gives
 *     the same animation in the React and the Svelte app.
 * R2  With reduced motion preferred, nothing animates: an entrance shows its
 *     end state at once and new data lands without tweening.
 * R3  Before its entrance, a component's animated parts are hidden only when
 *     scripts run and motion is allowed. Without scripts they are visible;
 *     they never flash at full size and then animate in.
 * R4  An entrance runs once per mount. "visible" waits until a quarter of the
 *     component has scrolled into view; replay by remounting.
 * R5  An update tween interrupted by newer data continues from wherever it
 *     had got to, never from the start.
 * R6  Server and client render the same final state; motion begins after
 *     hydration.
 *
 * # The animate helper — any element
 *
 *     enter?    preset | EnterSpec — once per element
 *     trigger?  "visible" (default) | "mount"
 *     targets?  a selector: animate these descendants in, staggered
 *     hover?    lift | grow | squish | { to, timing } — while pointed at
 *     press?    lift | grow | squish | { to, timing } — while pressed
 *     change?   { on, animation? } — pulse (default) | bump | flash | shake |
 *               { keyframes, timing }, played each time `on` changes
 *
 * R7  Hover and press move to a state and back to rest; pressing wins over
 *     hovering. Hover ignores touch. Press works from the keyboard (Enter,
 *     Space) on a focusable element.
 * R8  A change animation ends where it began, and never plays on first
 *     render. `on` is compared by identity: pass a primitive.
 * R9  Re-rendering never replays an entrance.
 * R10 Under reduced motion, gestures and change animations do nothing.
 *
 * # Presets
 *
 *     fade   opacity                         any mark
 *     rise   opacity + a short upward move   any mark
 *     grow   scale from the baseline         bars and columns
 *     wipe   revealed left to right          lines, areas, sparklines
 *     trace  stroke drawn along its path     donut segments
 *     pop    scale from the centre, springy  points and small marks
 *
 *     lift   up 3px            hover        pulse  scale 1 → 1.08 → 1   change
 *     grow   scale 1.03        hover        bump   up 6px and back      change
 *     squish scale 0.96        press        flash  opacity dips         change
 *                                           shake  side to side         change
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * specs.ts holds the types and presets; run.ts turns them into calls to
 * motion's framework-free animate(), stagger(), and inView(). react.ts (and
 * the Svelte app's equivalents) only wire those to a component's lifecycle.
 *
 * The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
 * <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
 * in the override layer, which hides the whole element while it is pending.
 *
 * R3: components render data-motion-pending; a stylesheet hides their
 * [data-mark] elements under @media (scripting: enabled) and
 * (prefers-reduced-motion: no-preference). runEnter removes the attribute in
 * the same task that starts the animation, so no frame paints in between.
 *
 * Updates tween the data, not the geometry: a record of numbers is mixed
 * frame by frame (a new key starts from zero), and the component redraws
 * from the mix. So stacks, arcs, and axes move together with no per-shape
 * interpolation.
 */
export {};

lib/motion/specs.ts

import type { DOMKeyframesDefinition } from "motion";

/* Animation as data. Nothing here imports a framework: the same specs run in
   the React and the Svelte app, through the same runner, so an effect defined
   once looks the same in both. */

/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
  | readonly [number, number, number, number]
  | "linear"
  | "easeIn"
  | "easeOut"
  | "easeInOut"
  | "backOut"
  | "circOut";

/** How long and how. Seconds throughout. */
export type Timing =
  | { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
  | {
      type: "spring";
      /** How long the spring appears to take; the tail settles after. */
      visualDuration?: number;
      /** 0 is no overshoot; 0.5 is very bouncy. */
      bounce?: number;
      delay?: number;
    };

/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
  keyframes: DOMKeyframesDefinition;
  timing?: Timing;
  /** Seconds between one mark's start and the next's. */
  stagger?: number;
};

/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";

export type ChartAnimation = {
  /** The marks' entrance; false shows them at once. */
  enter?: EnterPreset | EnterSpec | false;
  /** How marks move to new data; false jumps. */
  update?: Timing | false;
  /** Enter on mount, or the first time the chart scrolls into view. */
  trigger?: "mount" | "visible";
};

/** What a chart's `animation` prop takes: a preset name, a full spec, or
 *  false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;

/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";

export const DEFAULT_TIMING = {
  duration: 0.6,
  ease: [0.22, 1, 0.36, 1],
} satisfies Timing;

export const DEFAULT_UPDATE: Timing = {
  duration: 0.45,
  ease: [0.22, 1, 0.36, 1],
};

/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
 *  CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
  switch (preset) {
    case "fade":
      return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
    case "rise":
      return {
        keyframes: {
          opacity: [0, 1],
          transform: ["translateY(12px)", "translateY(0px)"],
        },
        stagger: 0.04,
      };
    case "grow":
      return {
        keyframes: {
          transform:
            axis === "y"
              ? ["scaleY(0)", "scaleY(1)"]
              : ["scaleX(0)", "scaleX(1)"],
        },
        stagger: 0.04,
      };
    case "wipe":
      return {
        keyframes: {
          clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
        },
        timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
        stagger: 0.12,
      };
    case "trace":
      // Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
      // the whole stroke and 0 shows it.
      return {
        keyframes: { strokeDashoffset: [1, 0] },
        timing: { duration: 0.5, ease: "easeInOut" },
        stagger: 0.5,
      };
    case "pop":
      return {
        keyframes: {
          opacity: [0, 1],
          transform: ["scale(0)", "scale(1)"],
        },
        timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
        stagger: 0.02,
      };
  }
}

/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
  prop: AnimationProp | undefined,
  defaults: { enter: EnterPreset; axis: GrowAxis },
): {
  enter: EnterSpec | null;
  update: Timing | null;
  trigger: "mount" | "visible";
} {
  if (prop === false) return { enter: null, update: null, trigger: "mount" };
  const options: ChartAnimation =
    typeof prop === "string" ? { enter: prop } : (prop ?? {});
  const enter = options.enter ?? defaults.enter;
  return {
    enter:
      enter === false
        ? null
        : typeof enter === "string"
          ? presetSpec(enter, defaults.axis)
          : enter,
    update:
      options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
    trigger: options.trigger ?? "visible",
  };
}

/* ── The general helper: any element, not only charts ── */

/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;

/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";

/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";

export type AnimateOptions = {
  /** Animate in: a preset or keyframes; false or omitted, no entrance. */
  enter?: EnterPreset | EnterSpec | false;
  /** When the entrance runs. Default "visible": first scrolled into view. */
  trigger?: "mount" | "visible";
  /** Animate these descendants in, staggered, instead of the element. */
  targets?: string;
  /** While the pointer is over it. Ignored on touch. */
  hover?: GesturePreset | GestureSpec;
  /** While it is pressed: pointer, or Enter/Space when focused. */
  press?: GesturePreset | GestureSpec;
  /** Play `animation` each time `on` changes (not on first render). */
  change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};

const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };

export const GESTURES: Record<GesturePreset, GestureSpec> = {
  lift: { to: { y: -3 }, timing: SNAPPY },
  grow: { to: { scale: 1.03 }, timing: SNAPPY },
  squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};

export const CHANGES: Record<ChangePreset, ChangeSpec> = {
  pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
  bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
  flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
  shake: {
    keyframes: { x: [0, -6, 6, -4, 4, 0] },
    timing: { duration: 0.4, ease: "easeInOut" },
  },
};

export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
  typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
  typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
  !value ? null : typeof value === "string" ? presetSpec(value, "y") : value;

/* ── Exits: an element leaving before it is removed ── */

/** Keyframes from the element's resting state to gone. Most overlays exit
 *  in CSS (their libraries wait for it); this is for lists a component
 *  manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";

export const EXITS: Record<ExitPreset, ExitSpec> = {
  fade: {
    keyframes: { opacity: [1, 0] },
    timing: { duration: 0.18, ease: "easeIn" },
  },
  "slide-right": {
    keyframes: {
      opacity: [1, 0],
      transform: ["translateX(0px)", "translateX(24px)"],
    },
    timing: { duration: 0.2, ease: "easeIn" },
  },
  "slide-down": {
    keyframes: {
      opacity: [1, 0],
      transform: ["translateY(0px)", "translateY(12px)"],
    },
    timing: { duration: 0.2, ease: "easeIn" },
  },
  shrink: {
    keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
    timing: { duration: 0.16, ease: "easeIn" },
  },
};

export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
  typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);

lib/motion/run.ts

import {
  animate,
  hover,
  inView,
  press,
  stagger,
  type DOMKeyframesDefinition,
  type Easing,
} from "motion";
import {
  changeSpec,
  exitSpec,
  DEFAULT_TIMING,
  gestureSpec,
  type AnimateOptions,
  type EnterSpec,
  type ExitPreset,
  type ExitSpec,
  type GestureSpec,
  type Timing,
} from "./specs";

/* The runner: turns specs into motion calls. Framework-free, so both apps
   call exactly this. */

export function prefersReducedMotion() {
  return (
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

const DEFAULT_DURATION = 0.6;

/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
  if (timing.type === "spring") {
    return {
      type: "spring" as const,
      visualDuration: timing.visualDuration ?? 0.5,
      bounce: timing.bounce ?? 0.25,
      delay: timing.delay ?? 0,
    };
  }
  return {
    duration: timing.duration ?? DEFAULT_DURATION,
    // Motion's type wants a mutable tuple; the spec's is readonly data.
    ease: (timing.ease ?? "easeOut") as Easing,
    delay: timing.delay ?? 0,
  };
}

/** The attribute a chart renders while its entrance has not run. CSS hides
 *  the marks under it — only when scripting is on and motion is allowed — so
 *  they do not flash at full size before animating in, and never stay hidden
 *  without JavaScript. */
export const PENDING = "data-motion-pending";

const TRANSFORMS = new Set([
  "x",
  "y",
  "z",
  "scale",
  "scaleX",
  "scaleY",
  "rotate",
  "rotateX",
  "rotateY",
  "skew",
  "skewX",
  "skewY",
]);

/** Remove what an entrance of `keyframes` left behind: its finished Web
 *  Animations, which motion keeps filling forwards (they would override any
 *  later animation of the same property — a hover lift, a change pulse), and
 *  the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
  for (const animation of mark.getAnimations())
    if (animation.playState === "finished") animation.cancel();
  const style = (mark as HTMLElement | SVGElement).style;
  for (const key of Object.keys(keyframes)) {
    const property = TRANSFORMS.has(key)
      ? "transform"
      : key.startsWith("--")
        ? key
        : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
    style.removeProperty(property);
  }
}

/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;

/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";

/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();

/**
 * Run an entrance on `root`'s marks (descendants matching `targets`, or the
 * root itself when `targets` is null), now or when it first scrolls into
 * view. Returns a cleanup that stops it.
 */
export function runEnter(
  root: Element,
  spec: EnterSpec | null,
  trigger: "mount" | "visible",
  targets: string | null = MARK,
): () => void {
  let controls: { stop: () => void } | undefined;
  const reveal = () => root.removeAttribute(PENDING);

  const start = () => {
    started.add(root);
    const marks = targets ? [...root.querySelectorAll(targets)] : [root];
    if (!spec || marks.length === 0 || prefersReducedMotion()) {
      reveal();
      return;
    }
    const options = toOptions(spec.timing);
    const animation = animate(marks, spec.keyframes, {
      ...options,
      // However many marks, the stagger never adds more than two seconds: a
      // few hundred points must not take minutes to arrive.
      delay: spec.stagger
        ? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
            startDelay: options.delay,
          })
        : options.delay,
    });
    controls = animation;
    // Same task as the animation's first frame, so nothing paints between.
    reveal();
    // An entrance ends at the mark's natural state, so its inline styles are
    // cleared once it finishes: a leftover clip-path or transform would
    // otherwise keep clipping strokes or fight the stylesheet.
    // Motion commits each element's final style as it finishes, which can
    // land after `finished` settles; clearing a frame later runs after it.
    animation.finished.then(
      () =>
        requestAnimationFrame(() =>
          marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
        ),
      () => {},
    );
  };

  if (trigger === "mount" || !spec) {
    start();
    return () => controls?.stop();
  }
  const stopWatching = inView(
    root,
    () => {
      start();
      stopWatching();
    },
    { amount: 0.25 },
  );
  return () => {
    stopWatching();
    controls?.stop();
  };
}

export type NumberRecord = Readonly<Record<string, number>>;

/** Where a key new in the target starts: from zero (a bar growing from its
 *  baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";

/** Mix two records by key. A key new in `to` starts from zero or at its
 *  target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
  from: NumberRecord,
  to: NumberRecord,
  t: number,
  fresh: Fresh = "zero",
): Record<string, number> {
  const out: Record<string, number> = {};
  for (const key in to) {
    const start = from[key] ?? (fresh === "target" ? to[key] : 0);
    out[key] = start + (to[key] - start) * t;
  }
  return out;
}

/**
 * Tween from one record of numbers to another, calling `onFrame` with the mix
 * each frame. Returns a stop function. With no timing, or reduced motion, it
 * lands on `to` at once.
 */
export function tweenRecord(
  from: NumberRecord,
  to: NumberRecord,
  timing: Timing | null,
  onFrame: (value: Record<string, number>) => void,
  fresh: Fresh = "zero",
): () => void {
  if (!timing || prefersReducedMotion()) {
    onFrame({ ...to });
    return () => {};
  }
  const controls = animate(0, 1, {
    ...toOptions(timing),
    onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
  });
  return () => controls.stop();
}

/**
 * An entrance that runs at most once per element, however often it is
 * attached (a re-render, a strict-mode double effect). The cleanup stops
 * waiting for visibility but lets a started animation finish.
 */
export function enterOnce(
  element: Element,
  spec: EnterSpec | null,
  trigger: "mount" | "visible",
  targets: string | null,
): () => void {
  if (started.has(element)) {
    element.removeAttribute(PENDING);
    return () => {};
  }
  let stopWatching = () => {};
  const run = () => {
    runEnter(element, spec, "mount", targets);
  };
  if (trigger === "mount" || !spec) run();
  else
    stopWatching = inView(
      element,
      () => {
        run();
        stopWatching();
      },
      { amount: 0.25 },
    );
  return () => stopWatching();
}

/* Motion keeps one `transform` value per element. An entrance that animates
   the `transform` string would win over later shorthand keys (y, scale), so
   gestures and changes turn their shorthands into a full transform string
   too: every animation on an element then moves the same value. */
const SHORTHAND = {
  x: 0,
  y: 0,
  scale: 1,
  scaleX: 1,
  scaleY: 1,
  rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;

function transformOf(values: Partial<Record<Shorthand, number>>) {
  const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
  const scale = values.scale ?? 1;
  return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}

/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
 *  may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
  keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  const shorthands: [Shorthand, number | number[]][] = [];
  for (const [key, value] of Object.entries(keyframes)) {
    if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
    else out[key] = value;
  }
  if (!shorthands.length) return out;
  const frames = Math.max(
    ...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
  );
  const frame = (index: number) =>
    transformOf(
      Object.fromEntries(
        shorthands.map(([key, v]) => [
          key,
          Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
        ]),
      ),
    );
  out.transform =
    frames === 1
      ? frame(0)
      : Array.from({ length: frames }, (_, i) => frame(i));
  return out;
}

/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
  element: Element,
  animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
  if (prefersReducedMotion()) return () => {};
  const spec = changeSpec(animation);
  const controls = animate(
    element,
    withTransform(
      spec.keyframes as Record<string, unknown>,
    ) as DOMKeyframesDefinition,
    toOptions(spec.timing),
  );
  return () => controls.stop();
}

/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
  x: 0,
  y: 0,
  z: 0,
  rotate: 0,
  rotateX: 0,
  rotateY: 0,
  skewX: 0,
  skewY: 0,
  scale: 1,
  scaleX: 1,
  scaleY: 1,
  opacity: 1,
};

/**
 * Move to `hover` while the pointer is over the element and to `press` while
 * it is pressed (pressing wins), and back to rest after. Nothing under
 * reduced motion. Returns a cleanup that unbinds.
 */
export function bindGestures(
  element: Element,
  hoverSpec: AnimateOptions["hover"],
  pressSpec: AnimateOptions["press"],
): () => void {
  const onHover = gestureSpec(hoverSpec);
  const onPress = gestureSpec(pressSpec);
  if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};

  const rest: Record<string, number | string> = {};
  const style = getComputedStyle(element);
  for (const spec of [onHover, onPress])
    for (const key of Object.keys(spec?.to ?? {}))
      rest[key] = REST[key] ?? style.getPropertyValue(key);

  let hovered = false;
  let pressed = false;
  // Motion reads a value it has never animated from the computed style, and
  // reads a computed `none` as a zeroed transform — scale 0. The first move
  // therefore starts explicitly from rest.
  let first = true;
  const restFrame = withTransform(rest).transform;
  const settle = (via: GestureSpec | undefined) => {
    const target = withTransform({
      ...rest,
      ...(hovered ? onHover?.to : {}),
      ...(pressed ? onPress?.to : {}),
    });
    if (first && restFrame !== undefined && target.transform !== undefined)
      target.transform = [restFrame, target.transform];
    first = false;
    animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
  };

  const cleanups: (() => void)[] = [];
  if (onHover)
    cleanups.push(
      hover(element, () => {
        hovered = true;
        settle(onHover);
        return () => {
          hovered = false;
          settle(onHover);
        };
      }),
    );
  if (onPress)
    cleanups.push(
      press(element, () => {
        pressed = true;
        settle(onPress);
        return () => {
          pressed = false;
          settle(onPress);
        };
      }),
    );
  return () => cleanups.forEach((cleanup) => cleanup());
}

/**
 * Animate an element out, resolving when it has gone (at once under reduced
 * motion). The caller removes it after: `await exitElement(el); remove()`.
 */
export async function exitElement(
  element: Element,
  exit?: ExitPreset | ExitSpec,
): Promise<void> {
  if (prefersReducedMotion()) return;
  const spec = exitSpec(exit);
  await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
    () => {},
  );
}

lib/motion/react.tsx

"use client";

import { Slot } from "@radix-ui/react-slot";
import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactElement,
  type RefObject,
} from "react";

import {
  bindGestures,
  enterOnce,
  PENDING,
  playChange,
  runEnter,
  tweenRecord,
  type Fresh,
  type NumberRecord,
} from "./run";
import {
  enterSpec,
  type AnimateOptions,
  type EnterSpec,
  type Timing,
} from "./specs";

/** Run an entrance once, when the element mounts. Change the element's `key`
 *  to replay it. */
export function useEnter(
  ref: RefObject<Element | null>,
  spec: EnterSpec | null,
  trigger: "mount" | "visible",
) {
  const once = useRef({ spec, trigger });
  useEffect(() => {
    if (!ref.current) return;
    return runEnter(ref.current, once.current.spec, once.current.trigger);
  }, [ref]);
}

/** A record of numbers that moves to each new target over `timing`. The first
 *  render returns the target itself, so server and client agree. A new target
 *  mid-tween starts from wherever the last one had got to. */
export function useTweened(
  target: NumberRecord,
  timing: Timing | null,
  fresh: Fresh = "zero",
) {
  const key = JSON.stringify(target);
  // The record is data, so its serialisation is its identity.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const to = useMemo(() => target, [key]);
  const [shown, setShown] = useState(to);
  const current = useRef(to);

  useEffect(() => {
    if (current.current === to) return;
    return tweenRecord(
      current.current,
      to,
      timing,
      (value) => {
        current.current = value;
        setShown(value);
      },
      fresh,
    );
  }, [to, timing, fresh]);

  return shown;
}

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Returns `[ref, props]`: put the ref on the element
 * and spread the props on it
 * (they carry the pre-entrance attribute, so nothing flashes on first
 * paint). Options are read as data: an entrance runs once; gestures rebind
 * when their specs change; `change.on` is compared by identity, so pass a
 * primitive.
 */
export function useAnimate<T extends Element = HTMLElement>(
  options: AnimateOptions,
) {
  const ref = useRef<T>(null);
  const initial = useRef(options);

  useEffect(() => {
    const { enter, trigger = "visible", targets } = initial.current;
    if (!ref.current || !enter) return;
    return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
  }, []);

  const gestureKey = JSON.stringify([options.hover, options.press]);
  const gestures = useMemo(
    () => ({ hover: options.hover, press: options.press }),
    // The specs are data, so their serialisation is their identity.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [gestureKey],
  );
  useEffect(() => {
    if (!ref.current) return;
    return bindGestures(ref.current, gestures.hover, gestures.press);
  }, [gestures]);

  const on = options.change?.on;
  const changeKey = JSON.stringify(options.change?.animation ?? null);
  const flourish = useMemo(
    () => options.change?.animation,
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [changeKey],
  );
  const last = useRef(on);
  useEffect(() => {
    if (!ref.current || Object.is(last.current, on)) return;
    last.current = on;
    return playChange(ref.current, flourish);
  }, [on, flourish]);

  return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}

export type AnimateProps = AnimateOptions & {
  /** One element or component that forwards its ref and props. */
  children: ReactElement;
};

/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
  const [animateRef, props] = useAnimate(options);
  return (
    <Slot ref={animateRef} {...props}>
      {children}
    </Slot>
  );
}

components/charts/_shared/chart.module.css

@layer primitive {
  .root {
    display: grid;
    min-width: 0;
    gap: var(--space-5);
    color: var(--ink);
    font-size: var(--text-13);
  }

  /* Before its entrance runs, a chart's marks are hidden — only when scripts
     run and motion is allowed, so they never flash at full size first and
     never stay hidden without JavaScript. The runner removes the attribute. */
  @media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
    .root[data-motion-pending] [data-mark] {
      opacity: 0;
    }
  }

  /* Transforms on SVG marks resolve against the mark's own box, so `grow`
     and `pop` scale from its baseline or centre, not the SVG's corner. */
  .root [data-mark] {
    transform-box: fill-box;
  }

  /* ── Cartesian plot: y labels | stretched SVG, then x labels ── */
  .cartesian {
    display: grid;
    grid-template-columns: calc(var(--axis-ch, 3) * 1ch) minmax(0, 1fr);
    grid-template-rows: var(--plot-h, 13rem) auto;
    column-gap: var(--space-4);
    container-type: inline-size;
    font-family: var(--font-mono);
    font-size: var(--text-11);
    font-variant-numeric: tabular-nums;
  }
  .yAxis {
    position: relative;
    color: var(--chart-label, var(--ink-3));
  }
  .yAxis span {
    position: absolute;
    right: 0;
    line-height: 1;
    transform: translateY(-50%);
    white-space: nowrap;
  }
  .area {
    position: relative;
    min-width: 0;
  }
  .svg {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
  }
  /* Printed values over the plot, placed by percentage. */
  .notes {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  .notes span {
    position: absolute;
    color: var(--chart-label, var(--ink-3));
    line-height: 1;
    transform: translate(-50%, calc(-100% - 4px));
    white-space: nowrap;
  }
  .xAxis {
    position: relative;
    height: 1.4em;
    grid-column: 2;
    margin-top: var(--space-3);
    color: var(--chart-label, var(--ink-3));
  }
  .xAxis span {
    position: absolute;
    top: 0;
    line-height: 1.4;
    transform: translateX(-50%);
    white-space: nowrap;
  }
  .xAxis span[data-align="start"] {
    transform: none;
  }
  .xAxis span[data-align="end"] {
    transform: translateX(-100%);
  }
  /* Wrapped: every label, as wide as its column, on up to three lines. */
  .xAxis[data-wrap] {
    height: 3.6em;
  }
  .xAxis[data-wrap] span {
    line-height: 1.2;
    text-align: center;
    white-space: normal;
  }
  /* Angled: every label, rotated about its end so it hangs under its bar. */
  .xAxis[data-angled] {
    height: calc(var(--label-ch, 4) * 0.7ch + 1em);
  }
  .xAxis[data-angled] span {
    transform: translateX(-100%) rotate(-40deg);
    transform-origin: right top;
  }
  @container (max-width: 34rem) {
    .xAxis span[data-tier="0"] {
      display: none;
    }
  }
  @container (max-width: 20rem) {
    .xAxis span:not([data-tier="2"]) {
      display: none;
    }
  }
  .grid {
    stroke: var(--chart-grid, var(--line));
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
  .zero {
    stroke: var(--chart-axis, var(--line-strong));
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }

  /* ── Marks. A series sets --series; its marks paint with it. ── */
  .column {
    fill: var(--series);
    transform-origin: 50% 100%;
  }
  .column[data-negative] {
    transform-origin: 50% 0;
  }
  .line {
    fill: none;
    stroke: var(--series);
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-width: 2;
    vector-effect: non-scaling-stroke;
  }
  .line[data-line="dashed"] {
    stroke-dasharray: 6 4;
  }
  .line[data-line="dotted"] {
    stroke-dasharray: 0.5 4.5;
  }
  .area {
    fill: color-mix(in oklab, var(--series) 16%, transparent);
    stroke: none;
  }
  .area[data-stacked] {
    fill: color-mix(in oklab, var(--series) 34%, transparent);
  }
  /* A zero-length stroke with round caps: a circle that stays round when the
     SVG stretches. */
  .point {
    stroke: var(--series);
    stroke-linecap: round;
    stroke-width: 7;
    vector-effect: non-scaling-stroke;
  }

  .empty {
    display: grid;
    min-height: 8rem;
    place-items: center;
    padding: var(--space-7);
    border: 1px dashed var(--line);
    border-radius: var(--radius-2);
    color: var(--ink-3);
    font-size: var(--text-13);
    text-align: center;
  }

  /* ── The exact values, as a table behind a disclosure ── */
  .data {
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .data > summary {
    width: fit-content;
    padding-block: var(--space-2);
    border-radius: var(--radius-1);
    cursor: pointer;
  }
  .data > summary:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .data[open] > summary {
    margin-bottom: var(--space-4);
  }
}

@layer primitive {
  /* ── Scatter marks: the plot keeps its aspect, so these stay round ── */
  .dot {
    fill: var(--chart-neutral);
    transform-origin: center;
  }
  .dot[data-state="up"] {
    fill: var(--chart-pos);
  }
  .dot[data-state="down"] {
    fill: var(--chart-neg);
  }
  .bubble {
    fill: var(--series);
    fill-opacity: 0.6;
    stroke: var(--surface-panel);
    stroke-width: 1.5;
    vector-effect: non-scaling-stroke;
    transform-origin: center;
  }
}

@layer primitive {
  .shape {
    fill: var(--series);
    fill-opacity: 0.85;
    stroke: var(--surface-panel);
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
  }
}

@layer primitive {
  /* ── Inspector: over the plot, reading one position at a time ── */
  .inspect {
    position: absolute;
    inset: 0;
    border-radius: var(--radius-1);
    cursor: crosshair;
    /* A horizontal drag scrubs; a vertical one still scrolls the page. */
    touch-action: pan-y;
  }
  .inspect:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 4px;
  }
  .crosshair,
  .inspectBand,
  .marker,
  .readout {
    position: absolute;
    pointer-events: none;
  }
  .crosshair {
    top: 0;
    bottom: 0;
    width: 0;
    border-left: 1px dashed var(--chart-axis, var(--line-strong));
  }
  .inspectBand {
    top: 0;
    bottom: 0;
    background: color-mix(in oklab, var(--ink) 6%, transparent);
  }
  .marker {
    width: 9px;
    height: 9px;
    border: 2px solid var(--surface-panel);
    border-radius: 50%;
    background: var(--series);
    box-shadow: 0 0 0 1px var(--series);
    transform: translate(-50%, -50%);
  }
  /* Beside the crosshair (at --x) on the side with room, and clamped inside
     the plot: on a narrow plot it slides to the edge rather than off it. */
  .readout {
    --readout-w: min(15rem, 100%);
    --gap: var(--space-5);
    top: 0;
    left: clamp(
      0px,
      calc(var(--x) + var(--gap)),
      calc(100% - var(--readout-w))
    );
    z-index: 1;
    display: grid;
    width: var(--readout-w);
    gap: var(--space-2);
    padding: var(--space-4) var(--space-5);
    border: 1px solid var(--line);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    box-shadow: var(--shadow);
    font-family: var(--font-sans);
    font-size: var(--text-12);
  }
  .readout[data-side="left"] {
    left: clamp(
      0px,
      calc(var(--x) - var(--gap) - var(--readout-w)),
      calc(100% - var(--readout-w))
    );
  }
  @media (prefers-reduced-motion: no-preference) {
    .crosshair,
    .inspectBand,
    .marker,
    .readout {
      transition:
        left var(--dur-1) var(--ease),
        top var(--dur-1) var(--ease);
    }
  }
  .readoutTitle {
    margin: 0;
    color: var(--ink);
    font-weight: var(--weight-strong);
  }
  .readoutRow {
    display: flex;
    align-items: baseline;
    gap: var(--space-4);
    margin: 0;
    color: var(--ink-2);
  }
  .readoutRow::before {
    width: 8px;
    height: 8px;
    flex: none;
    align-self: center;
    border-radius: 2px;
    background: var(--series);
    content: "";
  }
  .readoutRow[data-total] {
    padding-top: var(--space-2);
    border-top: 1px solid var(--line);
    color: var(--ink);
  }
  .readoutRow[data-total]::before {
    background: none;
  }
  .readoutLabel {
    flex: 1;
    min-width: 0;
  }
  .readoutValue {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }
  .readoutNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .readoutRow[data-missing] .readoutValue {
    color: var(--ink-3);
    font-family: var(--font-sans);
    font-style: italic;
  }
}

Edge cases

empty · unmeasured · one point · constant · gaps

No rows

No data to display.

Nothing measured

Measurements unavailable.

View data for Nothing measured
Nothing measured
LabelBalance
MonUnavailable
TueUnavailable
One measurement
Use the left and right arrow keys to read each position.
View data for One measurement
One measurement
LabelBalance
Today42
Constant zero
Use the left and right arrow keys to read each position.
View data for Constant zero
Constant zero
LabelBalance
Mon0
Tue0
Wed0
Thu0
Gaps and a lone point
Use the left and right arrow keys to read each position.
View data for Gaps and a lone point
Gaps and a lone point
LabelBalance
D14
D26
D3Unavailable
D45
D5Unavailable
D68
D79
D87
Nothing to share
  • Used0
  • FreeUnavailable