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:
| Content | AA | AAA |
|---|---|---|
| Body text | 4.5 : 1 | 7 : 1 |
| Large text (≥ 24px, or ≥ 18.66px bold) | 3 : 1 | 4.5 : 1 |
| UI components and graphical objects | 3 : 1 | — |
| Disabled controls, decorative elements | no 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.
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, visuallyPerceptual 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
borderfor structure (no requirement),border-strongfor anything that delimits an interactive control (3:1).- Actions, and their “on” pairs
action-primaryplustext-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:
- The ramp is not symmetric. If
neutral-100is much closer to white thanneutral-900is to black, the inverted pairs are not the mirror image of the light ones. Solving the ramp perceptually fixes this by construction. - 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-400where light mode usedindigo-600. - Pure black backgrounds.
#000maximises 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. - 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
- Pick one brand hue. Solve a perceptually even ramp from it, anchored on contrast targets rather than on the hex.
- Derive a neutral ramp with a trace of the brand hue in it, so greys sit with the palette rather than beside it.
- Assign semantic roles onto ramp steps — twice, once per mode. This is the only tier that gets two answers.
- Enumerate every foreground-and-background pairing the roles create, and check all of them at AA, in both modes.
- 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.
- 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.