Skip to content

How to build a WCAG-accessible colour palette

Contrast ratios, perceptual lightness, and why the palette that fails accessibility usually fails it in dark mode first. A method rather than a checklist.

By Srinidhi BhatUpdated 10 min read

Accessibility is not a property of a colour. It is a property of a pair — a foreground on a background — which is why a palette cannot be audited one swatch at a time, and why the palette that passes in light mode so often fails the moment it is inverted.

This guide is a method, not a checklist: how contrast is actually measured, why hand-picked tints produce uneven ramps, how to derive a palette that holds up, and where dark mode breaks the assumptions the light one was built on.

What WCAG actually requires

WCAG 2.x defines a contrast ratio between two colours, running from 1:1 (identical) to 21:1 (black on white). The thresholds that matter in practice:

ContentAAAAA
Body text4.5 : 17 : 1
Large text (≥ 24px, or ≥ 18.66px bold)3 : 14.5 : 1
UI components and graphical objects3 : 1
Disabled controls, decorative elementsno requirement

Two of those rows account for most real-world failures. UI components at 3:1 is the one teams forget entirely — it covers input borders, focus indicators, toggle tracks, icon-only buttons and chart strokes, none of which are text and all of which have to be perceivable. And disabled has no requirement, which is true and is not permission: a disabled control nobody can read is still a control nobody can read.

Why hand-picked tints produce uneven ramps

The usual way a palette is built: take the brand hex, then lighten and darken it a few times until you have nine steps. The result almost always has a cliff in it — two adjacent steps that look nearly identical, and two others that jump.

The cause is that the numbers we lighten with are not perceptual. Sliding the L in HSL, or scaling RGB channels, moves through a space where equal numeric steps are not equal visual steps: human vision is far more sensitive to lightness differences in the mid-range than at either end, and green reads much lighter than blue at the same nominal value.

The same nominal step, two different spaces
HSL lightness   50% → 60%   a small, uneven visual jump
                80% → 90%   a much larger one, same 10 points

OKLCH lightness 0.50 → 0.60  a step
                0.80 → 0.90  the same step, visually

Perceptual spaces — OKLCH, and to a lesser extent LCH and LAB — are built so that equal numeric distances are roughly equal perceived distances. Solving a ramp in one of them gives you steps that are evenly spaced to the eye, which has a direct accessibility consequence: if step 600 is always the same perceived lightness, then white on brand-600 passes for every hue you might choose, rather than for the three you happened to test.

Anchor the ramp on contrast, not on the brand colour

The stronger version of this: rather than treating the brand hex as the middle of the ramp and generating outward, treat the contrast targets as fixed and solve for the colours that hit them. The brand colour then lands on whichever step it is closest to, and every other step is a known quantity.

That is the difference between a ramp that is interpolated and one that is solved, and it is why a generated palette can promise accessibility for a hue it has never seen: nothing about the guarantee depends on which hue you picked.

The roles you actually need

A palette is not a set of colours; it is a set of answers. The minimum that covers a real interface:

Surfaces
surface, surface-raised, surface-sunken. Three is usually enough. Each one needs a text colour that passes on it.
Text
text, text-muted, text-subtle. Muted is where AA is most often lost — it is defined by wanting less contrast, which is the same thing as wanting to fail.
Borders
border for structure (no requirement), border-strong for anything that delimits an interactive control (3:1).
Actions, and their “on” pairs
action-primary plus text-on-action-primary. Storing the pair together is what makes the pairing checkable at all.
Status
Success, warning, danger, info — each needing a surface, a text and a border variant, and each needing to be distinguishable from the others without relying on hue alone.
Focus
A ring that reaches 3:1 against every surface it can appear on. This is the single most commonly broken requirement in custom design systems.

Dark mode is where it breaks

A palette that passes in light mode routinely fails when inverted, and almost always for one of these four reasons:

  1. The ramp is not symmetric. If neutral-100 is much closer to white than neutral-900 is to black, the inverted pairs are not the mirror image of the light ones. Solving the ramp perceptually fixes this by construction.
  2. Saturated colours are heavier on dark. A vivid brand colour that reads well on white is often glaring and lower-contrast on near-black. Dark mode usually wants a lighter, slightly desaturated step — indigo-400 where light mode used indigo-600.
  3. Pure black backgrounds. #000 maximises the ratio and reads badly: high contrast against pure black causes visible halation, especially for astigmatic readers. A very dark neutral is both more comfortable and easier to build elevation on.
  4. Elevation inverts. In light mode, raised surfaces get lighter and shadows do the work. In dark mode, shadows are nearly invisible; raised surfaces have to get lighter too, which means the surface ramp cannot simply be reversed.

A workable process

  1. Pick one brand hue. Solve a perceptually even ramp from it, anchored on contrast targets rather than on the hex.
  2. Derive a neutral ramp with a trace of the brand hue in it, so greys sit with the palette rather than beside it.
  3. Assign semantic roles onto ramp steps — twice, once per mode. This is the only tier that gets two answers.
  4. Enumerate every foreground-and-background pairing the roles create, and check all of them at AA, in both modes.
  5. Where a pair fails, move the step, not the hue. Going from 600 to 700 keeps the colour identity; nudging the hex breaks the ramp’s evenness.
  6. Re-run the whole check whenever anything upstream changes. Every time.

Checking it, and keeping it checked

Standalone contrast checkers are useful for a single decision and useless as a process — they tell you about one pair, at one moment, and nothing re-tests the other forty when you change a primitive.

What you want is the audit running continuously against the pairings your roles define. That is how the contrast engine in Arkitype works: every pairing is re-evaluated in light and dark as you edit, and a failure comes with the smallest reversible change that fixes it — usually one step along a ramp — rather than a red cross and a number.

  • Do not rely on hue alone to carry meaning; pair colour with an icon, a label or a shape.
  • Test the focus ring on every surface it can land on, not just the page background.
  • Check text over images and gradients — the worst-case pixel is the one that has to pass.
  • Treat AA as the floor, not the goal. Aiming for AA exactly means every rounding error is a failure.

Build the system, not just the swatches

Arkitype does everything on this page from one brand colour — tiered tokens, semantic roles, contrast checked live against WCAG, and output for Figma, Tailwind, MUI and CSS that is all generated from the same source.

Keep reading