Charts and Data Visualization
A chart answers one question, drawn so the answer is legible before the labels are read. Default posture: pick the mark from the question and the shape of the data, give it the least chrome that stays readable, and make everything derived from the data move together when the data changes. Most bad charts are not ugly — they are a mark that cannot express the question (a pie for a comparison, a line across a gap, a truncated bar), wrapped in gridlines and a legend that make the failure harder to see.
The boundary with the siblings that look like this one: rows and cells are dense-ui; axes and marks are here. motion owns every curve and duration — this skill owns only which values share one interpolation. Hues, ramps, and contrast ratios come from color; here we decide what a channel encodes, not what it is set to.
Draw with whatever the project already draws with. Check for an existing charting layer — Recharts, visx, Chart.js, ECharts, D3, hand-rolled SVG — and work inside it, taking series colors from the project's palette tokens rather than the library's defaults, which are the loudest tell that nobody chose them. A chart fix never justifies a second charting library, and library selection is out of scope unless explicitly asked for (pick-library).
Quick Reference
| Open it when | File |
|---|---|
| You know the data's shape and the reader's question but have not committed to a mark — per-mark recipes and the wrong choice each is confused with | chart-picker.md |
| You are mapping a variable to color, shape, or size, or choosing between categorical, sequential, and diverging palettes | encoding.md |
Decision: which mark?
Rank channels by how precisely a reader decodes them — position, then length, then angle, then area, then color — and give the question's answer to the highest one available.
| The reader's question | Mark |
|---|---|
| How did this change over time? | Line (evenly sampled) or bars (sparse, discrete periods) |
| Which is bigger? | Bar — horizontal for long labels, vertical when the axis is time-like |
| What is this made of? | Stacked bar; a pie only for two or three parts |
| How is it distributed? | Histogram |
| Do these two move together? | Scatter |
| Is the trend up or down, in this cell? | Sparkline, no chrome |
| What is the value right now? | A number in tabular-nums — not a chart |
Core Principles
Bars start at zero; lines need not. A bar encodes magnitude by length, so a truncated baseline multiplies the apparent difference by an arbitrary factor. Set the bar domain to
[0, max]. Exception: a log scale, or an index series baselined at100by construction — label that baseline on the axis.Round the ticks, not the data. Readers interpolate between ticks and only do it accurately when the steps are human:
1,2, or5times a power of ten. Let the scale choose the step and the domain grow to meet it. Exception: time axes, which tick on calendar boundaries.One axis gets gridlines, behind the marks. Gridlines on both axes of a small chart produce a mesh that competes with the data; the reader measures against one axis at a time. Use the lightest hairline the surface allows (
surfacesowns the value). Exception: scatter plots, where both coordinates are read.Hold the domain still across refreshes. A Y-axis recomputed on every poll makes an unchanged series appear to move — the fastest way to make live data untrustworthy. Fix the domain; expand only when data leaves it. Exception: a deliberately auto-ranging monitor, where the range change is itself the signal.
One clock, one interpolation. Everything derived from the domain reads from the same interpolated
t, or the chart comes apart into independently updating parts. Benji Taylor, on building Liveline: "The Y-axis range, the badge, the grid labels all use the same lerp. That's why it feels like one thing breathing rather than a bunch of parts updating independently."motionowns the curve and duration; this skill owns the shared clock. Exception: values not derived from the domain — a category name, a status label — do not interpolate. They swap.Never animate the first draw of static data. A self-drawing line delays the reading in exchange for decoration, and the reader came for the number.
motion-opportunitiesrejects exactly this case: an animated line on an analytics graph is functional data the user is reading, and decoration hinders. Exception: an explanatory or marketing chart where the drawing is the explanation —marketing-pagesruns looser motion rules than product UI.Hue encodes category; lightness and chroma encode magnitude. Rainbow ramps are non-monotonic in lightness, so they invent boundaries the data does not contain. A sequential ramp varies lightness along one hue; a diverging ramp is two sequential ramps meeting at a neutral midpoint that must be a real, meaningful zero. Exception: a two-class distinction (ours/theirs) may use lightness — with two classes there is no ordering to imply.
Direct-label the series and the legend disappears. A legend costs a lookup round trip per series and caps how many categorical colors a reader can hold; a label at the end of each line costs nothing and removes the cap. Exception: stacked or dense charts where labels collide — then a legend, ordered top-to-bottom to match the stacking, never alphabetically.
Never encode meaning with color alone. A reader with a color vision deficiency cannot separate your two series, and every chart is one grayscale print from the same failure. Add a second channel: dash pattern, marker shape, or a direct label.
a11yowns the figure's text alternative. Exception: a heatmap where the color scale is the chart — there the second channel is the value printed in the cell.A tooltip is a readout, not a hover effect. Show every series at the hovered x, snapped to the nearest data point rather than tracking the raw cursor, rows ordered as the marks stack. Showing only the nearest single mark makes comparison impossible exactly when it was wanted. Exception: scatter plots, where the tooltip belongs to one point and snapping across x is meaningless.
Missing data must look missing. Connecting across a gap invents values nobody measured, and downstream no one can tell which points were real. Break the line. Exception: a series where absence genuinely means zero — plot
0and say so in the axis label.Sparklines carry no chrome. No axes, gridlines, legend, or tooltip — one current value beside the line and the range as text. If it needs a tooltip to be useful, promote it to a chart. Exception: a single endpoint marker, which is a label rather than chrome.
Smell / Fix
| Smell | Fix |
|---|---|
| Bar chart with a non-zero baseline | Domain [0, max], or switch to a line |
Ticks at 0, 37, 74, 111 |
Nice steps: 1 / 2 / 5 × 10ⁿ |
| Gridlines on both axes of a small chart | Keep the axis being measured |
| Y-axis rescaling on every poll | Fix the domain; expand only on overflow |
| Axis label snaps while the line eases | Everything domain-derived shares one lerp |
| Line draws itself on every page load | No entrance animation on data being read |
| Rainbow ramp for a quantity | Sequential ramp: one hue, varying lightness |
| Legend with eight entries | Direct labels, or group the tail into "Other" |
| Two series separable only by hue | Add dash, shape, or a direct label |
| Tooltip shows one series at a time | All series at the hovered x, in stacking order |
| Straight segment across a data outage | Break the line |
1,284.3617 on a dashboard |
Round the display; full precision in the tooltip |
| Pie with nine slices | Horizontal bars, sorted by value |
Output: the chart spec
Before writing chart code, emit the spec and get it agreed:
Question: Which endpoints got slower this week?
Mark: Horizontal bars, sorted descending
X domain: [0, max] — length encodes magnitude
Y: Endpoint (categorical, direct-labeled, no legend)
Encoding: One hue; delta as a second muted bar, not a second color
Gridlines: X only, hairline, behind marks
Missing: Endpoint absent → omitted, counted in the caption
Live: None (snapshot) — no entrance animation
Checklist
- The mark answers the stated question on the highest-precision channel available
- Bars baselined at zero; any non-zero baseline is labeled
- Ticks on
1/2/5× 10ⁿ steps, or calendar boundaries for time - Gridlines on one axis only, behind the marks, hairline weight
- Domain stable across refreshes; all domain-derived values share one interpolation
- No entrance animation on data the user is reading
- Hue for category, lightness for magnitude; every color distinction has a second channel
- Series direct-labeled where possible; legend ordered to match stacking
- Tooltip shows all series at the hovered x, snapped to real data points
- Gaps rendered as gaps; zeros as zeros; displayed precision is real
- Sparklines carry no axes, gridlines, or tooltips