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
Workspaces with at least one sign-in in the month.
- This year
- Last year
View data for Active workspaces by month
| Label | This year | Last year |
|---|---|---|
| Oct | 1,180 | 840 |
| Nov | 1,242 | 876 |
| Dec | 1,204 | 910 |
| Jan | 1,310 | 902 |
| Feb | 1,398 | 954 |
| Mar | 1,467 | 1,003 |
| Apr | 1,520 | 1,041 |
| May | 1,604 | 1,066 |
| Jun | 1,688 | 1,102 |
| Jul | 1,731 | 1,150 |
| Aug | 1,840 | 1,162 |
| Sep | 1,962 | 1,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
Stacked: the top edge is the total.
- API
- Storage
- Compute
View data for Weekly requests by product, thousands
| Label | API | Storage | Compute |
|---|---|---|---|
| W27 | 42k | 18k | 9k |
| W28 | 45k | 19k | 11k |
| W29 | 44k | 21k | 10k |
| W30 | 51k | 21k | 14k |
| W31 | 55k | 22k | 13k |
| W32 | 53k | 24k | 16k |
| W33 | 58k | 25k | 18k |
| W34 | 61k | 25k | 17k |
| W35 | 66k | 27k | 21k |
| W36 | 64k | 29k | 24k |
| W37 | 70k | 30k | 23k |
| W38 | 74k | 31k | 27k |
One series, filled to zero.
View data for Weekly API requests, thousands
| Label | API |
|---|---|
| W27 | 42k |
| W28 | 45k |
| W29 | 44k |
| W30 | 51k |
| W31 | 55k |
| W32 | 53k |
| W33 | 58k |
| W34 | 61k |
| W35 | 66k |
| W36 | 64k |
| W37 | 70k |
| W38 | 74k |
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
Grouped.
- Signups
- Activated
View data for Signups and activations by month
| Label | Signups | Activated |
|---|---|---|
| Apr | 420 | 251 |
| May | 468 | 290 |
| Jun | 455 | 268 |
| Jul | 512 | 331 |
| Aug | 540 | 362 |
| Sep | 601 | 410 |
Stacked.
- Starter
- Team
- Enterprise
View data for Monthly recurring revenue by plan
| Label | Starter | Team | Enterprise |
|---|---|---|---|
| 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 |
Below zero grows downward. W36 was not measured.
View data for Net member change by week
| Label | Net members |
|---|---|
| W33 | 12 |
| W34 | 7 |
| W35 | -4 |
| W36 | Unavailable |
| W37 | 9 |
| W38 | -11 |
| W39 | 3 |
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
The default.
View data for Active workspaces, linear
| Label | This year |
|---|---|
| Feb | 1,398 |
| Mar | 1,467 |
| Apr | 1,520 |
| May | 1,604 |
| Jun | 1,688 |
| Jul | 1,731 |
| Aug | 1,840 |
| Sep | 1,962 |
Monotone: never overshoots a point.
View data for Active workspaces, smooth
| Label | This year |
|---|---|
| Feb | 1,398 |
| Mar | 1,467 |
| Apr | 1,520 |
| May | 1,604 |
| Jun | 1,688 |
| Jul | 1,731 |
| Aug | 1,840 |
| Sep | 1,962 |
A value that holds until it changes.
View data for Active workspaces, stepped
| Label | This year |
|---|---|
| Feb | 1,398 |
| Mar | 1,467 |
| Apr | 1,520 |
| May | 1,604 |
| Jun | 1,688 |
| Jul | 1,731 |
| Aug | 1,840 |
| Sep | 1,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
Percent columns: each month's shares.
- Starter
- Team
- Enterprise
View data for Share of recurring revenue by plan
| Label | Starter | Team | Enterprise |
|---|---|---|---|
| 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 |
Percent area, smoothed.
- API
- Storage
- Compute
View data for Share of weekly requests by product
| Label | API | Storage | Compute |
|---|---|---|---|
| W27 | 42k | 18k | 9k |
| W28 | 45k | 19k | 11k |
| W29 | 44k | 21k | 10k |
| W30 | 51k | 21k | 14k |
| W31 | 55k | 22k | 13k |
| W32 | 53k | 24k | 16k |
| W33 | 58k | 25k | 18k |
| W34 | 61k | 25k | 17k |
| W35 | 66k | 27k | 21k |
| W36 | 64k | 29k | 24k |
| W37 | 70k | 30k | 23k |
| W38 | 74k | 31k | 27k |
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
Support has zero seats; Finance was not reported.
A fixed maximum: a full bar is the 1 TB quota.
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
| Workspace | Requests | Last 30 days |
|---|---|---|
| Northstar | 1.4M | |
| Atlas | 812K | |
| Juniper | 96K |
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
- Free1,840 · 63.9%
- Starter612 · 21.2%
- Team388 · 13.5%
- Enterprise41 · 1.4%
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
- Signups
- Activated
View data for Signups and activations by month
| Label | Signups | Activated |
|---|---|---|
| Apr | 420 | 251 |
| May | 468 | 290 |
| Jun | 455 | 268 |
| Jul | 512 | 331 |
| Aug | 540 | 362 |
| Sep | 601 | 410 |
- This year
- Last year
View data for Active workspaces by month
| Label | This year | Last year |
|---|---|---|
| Oct | 1,180 | 840 |
| Nov | 1,242 | 876 |
| Dec | 1,204 | 910 |
| Jan | 1,310 | 902 |
| Feb | 1,398 | 954 |
| Mar | 1,467 | 1,003 |
| Apr | 1,520 | 1,041 |
| May | 1,604 | 1,066 |
| Jun | 1,688 | 1,102 |
| Jul | 1,731 | 1,150 |
| Aug | 1,840 | 1,162 |
| Sep | 1,962 | 1,175 |
- 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 data to display.
Measurements unavailable.
View data for Nothing measured
| Label | Balance |
|---|---|
| Mon | Unavailable |
| Tue | Unavailable |
View data for One measurement
| Label | Balance |
|---|---|
| Today | 42 |
View data for Constant zero
| Label | Balance |
|---|---|
| Mon | 0 |
| Tue | 0 |
| Wed | 0 |
| Thu | 0 |
View data for Gaps and a lone point
| Label | Balance |
|---|---|
| D1 | 4 |
| D2 | 6 |
| D3 | Unavailable |
| D4 | 5 |
| D5 | Unavailable |
| D6 | 8 |
| D7 | 9 |
| D8 | 7 |
- Used0
- FreeUnavailable