Skip to content

Sales materials

Charts and data viz

One spec covers charts in an article, on a slide and inside a client PDF. The PNG renderer screenshots the live HTML, so the interactive version and the static one can never diverge in styling.

Source of truth: varick-charts/references/chart-spec.md

The ramp

Five steps from navy through grey, plus the accent as the second series. Monochromatic plus one blue.

Chart 1

chart-1

Chart 2

chart-2

Chart 3

chart-3

Chart 4

chart-4

Chart 5

chart-5

Past five series, interpolate

Interpolate between navy and grey rather than introducing a new hue. If a request references a yellow “golden quadrant” or a pink chart, recolour it into this ramp, the reference is to the treatment, never to the colour.

chart-spec.md is authoritative here, not globals.css. The two disagree on --chart-3/4/5. See known drift.

Picking a chart type

Start from what the reader needs to do with the data, not from what the data looks like.

The data or intentChart type
Rank items by one metric, with detail on clickStandings: horizontal bars
Compare a few items on one metricColumn bar
Compare items on several metricsGrouped bars
Parts summing to 100% across a few rowsStacked horizontal bars
Composition of a few periods, with values and share %Labelled stacked column
Two numeric dimensions plus a size dimension, by categoryBubble scatter
Trend of one to three series over an ordered axisLine
A single trend where volume mattersArea, soft fill
Distribution or histogramColumn bar
Two numeric dimensions: cost against qualityScatter
One category's share of a wholeDonut
Several views the reader toggles betweenTabbed dashboard

Default to standings

For anything called a leaderboard, a ranking, a benchmark result, or “which model is best”. It carries a rank number, the metric bar, and an expandable detail row, which is almost always what the question actually wanted.

Card and grid

The frame around every chart. Fixed, so a page of charts reads as one set.

Card
White, 1px #EBEBEB, radius 12, padding 24.
Card title
The 10px uppercase label look, or a 14–22px heading at 500. Pick one, not both.
Card description
12px grey, weight 400.
Grid
strokeDasharray "4 4", stroke var(--border), horizontal lines only for time and category charts. Both axes for scatter.
Tooltip
White, 1px border, radius 8, shadow 0 8px 24px rgba(0,0,0,0.08). Swatch, grey label, value at 500 with tabular-nums. cursor={false} on bar and scatter.
Tabs
Hairline-bordered inline row, radius 6. Active is navy fill with white text at 500; inactive is transparent grey. Content switches instantly.

Radii

ElementRadiusNote
Card8pxn/a
Control, segmented4pxn/a
Bar4px[4,4,0,0] for columns
Track4pxn/a

Type

Card label
10px uppercase, 0.04em, 400
Card heading
14–22px, 500
Axis tick
12px, 400
Tooltip
12px
Standings name
14px, 500
Value
13px, 500, tabular-nums
Legend
12px, 400

Type inside a chart

Helvetica Neue, 400 or 500. This is the rule with the fewest exceptions in the whole system. There are none.

* {
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-synthesis: none;
}

Forbidden inside a chart

Weights 600 and above, or the keyword bold. Synthesised italic. Any monospace face, including Geist Mono. Any serif or display face. If you catch one in a draft, fix it before delivering. A 700 weight in a chart is visible from across a room.

For a label that wants to look like code, use uppercase at 0.04em tracking, weight 400, 10px, in --muted-foreground. That is the sanctioned substitute for a mono face.

Motion

Charts enter once and then hold still.

  • Bars fill via transform: scaleX() from a left center origin, 800ms.
  • Lines and areas draw in via Recharts animationDuration, 900ms.
  • Standings fill 280ms on cubic-bezier(0.23,1,0.32,1), staggered 30ms per row.
  • Tab switches are instant. Never animate a keyboard-triggered action.
  • Under prefers-reduced-motion, standings bars start filled and charts set isAnimationActive={false}.

Gradients

The sanctioned way to make a chart feel less plain without breaking the flat, hairline discipline. They stay inside the navy-to-accent ramp.

Line stroke

A horizontal navy-to-accent stroke gradient across the x-axis, which reads as progression over time, plus a faint fade-down fill. Use an AreaChart so the fill comes for free, and bump strokeWidth to about 2.5 so the gradient has room to read.

Area fill

The standard: a vertical stopOpacity from 0.3 to 0 in one colour.

Gradient-fade bars

Each column is a vertical fade from a saturated accent at the top to near-transparent at the baseline, topped by a small solid navy cap. Pair it with a dashed threshold line carrying a filled navy pill: the “TOP 1%” badge. Implement the bar as a custom Recharts shape so you control both the fade rect and the cap.

h("linearGradient", { id: "barFade", x1:"0", y1:"0", x2:"0", y2:"1" },
  h("stop", { offset:"0%",   stopColor:"var(--chart-2)", stopOpacity:0.95 }),
  h("stop", { offset:"55%",  stopColor:"var(--chart-2)", stopOpacity:0.35 }),
  h("stop", { offset:"100%", stopColor:"var(--chart-2)", stopOpacity:0.02 }))

Every gradient gets defined in a <defs> inside the chart and referenced by url(#id). Never inline a hex into a stop. Use the ramp variables so a palette change propagates.

Delivery

Two formats, and the choice is made by where the chart lands.

DestinationFormatHow
Blog or articleSelf-contained HTMLCharts stay hoverable and toggleable.
Client document or slidePNGrender_png.py --scale 2 for screen, 3 for print.
LinkedIn carousel slidePNG at scale 3It gets rasterised into a PDF and re-compressed, so it needs the headroom.
A machine with no networkInlined HTMLInline the three UMD bundles, keep the app in React.createElement form so nothing needs unsafe-eval.
python scripts/render_png.py chart.html \
  --selector "#capture" \
  --scale 3 \
  --width 720 \
  --wait 1400

The renderer waits for the mount animation and for fonts to settle, then screenshots the #capture element. Pass --transparent for the card only, without the page ground behind it.