Skip to content

Contrast and readability

Contrast is the difference in lightness between two colors, written as a ratio. Black on white is 21:1. Two shades of the same gray might be 1.4:1. Lattice holds every theme to two floors, and the difference between them is the most useful thing on this page.

4.5:1 for anything with words in it. Body prose, headings, captions, page numbers, code comments, the label inside a chart segment. This is the WCAG AA standard — the accessibility bar most organizations are held to — and a theme that ships has to clear it.

3:1 for anything without words. A rule between rows, the border of a diagram box, the tick marking an empty cell. These are shapes, not sentences; a reader has to see them, not read them.

That is why the ink ramp has two bottom rungs that look almost the same. --text-muted is quiet text and takes the 4.5:1 floor. --muted-mark is decoration and takes 3:1. Use the wrong one and you get a caption that passes every automated check and is still too faint to read — because the check was never asked about that color.

When a value fails, there are two directions to move, and only one is right.

Right: darken the ink, or lighten the surface. Hold the hue and the saturation and move only the lightness, so the color still belongs to your palette.

Wrong: decide that this particular caption does not really need to be readable.

The repo has a helper for the right move — ensureContrast in lib/theme/color.js walks a color’s lightness until it clears a target while holding its hue and chroma. Reach for it rather than nudging hex digits by eye.

The lab starts compliant. Break it and watch what actually happens on the slide — not in a report.

Push a color until it fails

Try this. Lighten --text-body one step at a time: #1C2A3E, #55637A, #8A94A6, #B4BBC7. Somewhere in there it stops being quiet and starts being unreadable, and it happens sooner than the hex codes suggest. Try to name the step where it crosses.

A color that clears 4.5:1 on your light background can fail on the dark one, and vice versa. Every theme file that supports dark mode is really two palettes, and both have to hold.

The lab above cannot show you that, because the theme it starts from sets one flat value per color and so has only one canvas. Giving a theme its second canvas is the next page — and once you have, every lab from there on carries a Dark button that genuinely switches.

You do not have to compute ratios by hand. Three things run over a palette:

Terminal window
node tools/contrast-audit.js # every text token against its surface
node tools/cvd-audit.js # how the palette reads to color-blind viewers
npm run build:check # the gates, including the categorical contract

build:check is the one that blocks. It holds the ink-on-fill and mark-on-canvas floors across your whole categorical cycle, in both canvases, and it fails the build rather than warning.

Every ratio you just checked has to hold twice — once on a light page, once on a dark one. One file covers both: Light and dark.