The quiet, considered look is a few fixed specs plus two pieces of negative space. The data is the only thing allowed to be loud.
| Mark | Spec |
|---|---|
| Bar / column | ≤ 24px thick (cap it — never fill the slot; let the band's leftover be air); 4px rounded data-end, square at the baseline; grows from a single baseline |
| Line | 2px, round join/cap |
| Marker / end-dot | ≥ 8px (r ≥ 4), filled with the series color |
| Area fill | the series hue at ~10% opacity (a wash, never a saturated block) |
| Gridlines / axes | one-step-off-surface gray, hairline (1px), solid (never dashed), recessive |
- Surface gap. A 2px gap in the surface color separates touching marks — every segment of a stacked bar, and every adjacent (touching) bar, the same width. Keep it one consistent width across a stack; neighbors one step apart read distinct because of the gap, not a stroke drawn around them.
- Surface ring. Dots and end-markers carry a 2px ring in the surface color,
so they stay legible where they cross a line or overlap each other. The ring is part
of the mark's hover/hit target, not just spacing — see
interaction.md(small dots are easy to under-size for hover).
Never draw a border around a mark to separate it. The gap and the ring are the mechanism; a stroke adds data-weight ink that isn't data.
A legend is always present for two or more series — the dependable identity channel; never make the reader rely on color-matching alone. Direct labels then ride the marks to supplement it. A single series needs no legend box: there is only one color, so the chart's title or subtitle already says what is plotted. A box with one swatch restates the title and costs space.
- Label selectively — never a number on every point. A value beside every dot or segment is chaos and goes unread. Label the endpoint, the extreme, or the one series the story is about; let the axis, the legend, and the tooltip/table carry the rest. Direct labels work because they are sparing — flood the chart and they stop working.
- Direct labels before gridlines; gridlines before a second axis.
- A label that won't fit doesn't get clipped — measure first. Only place a label
inside a bar or stacked segment when the rendered text fits with comfortable
padding on both sides. If it doesn't fit: for a whole bar/column, move the label
outside the bar end (or to the tooltip if there's no room outside either); for an
interior stacked segment (which has no free end),
skip the inline label and let the legend + tooltip carry it. Either way the value
stays in the table view, so nothing is gated. Never use
overflow: hiddenon the segment to "solve" it — that crops the first/last characters and is worse than no label. Text never overflows or is clipped by its own mark. - Bars → value at the tip. Columns → value on the cap. Lines → value at the end.
- Y-axis ticks: round to clean numbers (0 / 1,000 / 2,000), thousands-comma'd; they carry the values you didn't directly label, so keep them unless every value is labeled.
- Text never wears the data color. Marks — bars, lines, dots, area fills — carry the series color; labels, values, legends, and axis text use text tokens (primary / secondary / muted). A light categorical hue (yellow, aqua) is illegible as text on the surface. Identity comes from the colored mark beside the text — a dot, a short line-key, a swatch — never from coloring the text itself. A label set inside a colored fill (a stacked segment, a map tile) is the one exception: pick white or ink by the fill's luminance so it always clears contrast.
- When end-labels collide, don't stack them. Direct end-labels work when series separate at the right edge. When lines converge, nudging labels apart vertically detaches them from their lines and reads as noise — instead use leader lines (a thin connector from label to line-end), facet into small multiples, or fall back to the legend + tooltip. Past ~4 converging series, small multiples is usually right.
- Stat tile contract:
label(sentence case, no trailing colon) ·value(Sans semibold, auto-compact: 1,284 / 12.9K / $4.2M) ·delta(optional; signed, vs a named period; color = direction × whether up is good) ·trend(optional; 12-point sparkline in the de-emphasis hue, current period in the accent). - Meter: the fill carries severity (accent → warning → danger); the unfilled track is a lighter step of the same ramp (blue-on-blue, etc.) so state reads across the whole bar.
- Hero figure. The single number a dashboard leads with, ≥48px, in the same sans as everything else (never a display or serif face — it reads as off-brand decoration). Exactly one per view.
- Proportional figures for big numbers; tabular only in columns. A large
standalone value (hero figure, stat-tile value) uses the font's default
proportional figures —
tabular-numsgives every digit the width of a0, so a number like121looks loose at display sizes. Reservefont-variant-numeric: tabular-numsfor columns of numbers that must align vertically (table rows, axis ticks).
Where hue fails — full-severity CVD, grayscale print, forced-colors — texture
carries identity. One directional hand-drawn fill, used at 45° and its 135° mirror
only (never horizontal/vertical — those read as gridlines/bars). Inked tone-on-tone
(a step from the fill's own ramp), equal loudness across slots. On value scales the
texture is ordered (rotation steps with magnitude; arm angle carries the diverging
sign) so it never misstates the value. Triggered by an accessibility setting, print,
or forced-colors — never on by default. (See palette.md.)