Why palettes fail
A palette generator shows you five colors in a row, all the same size, none of them on top of another. An interface shows you text on a background, a label on a card, a button fill under white type. Every one of those is a pair, and a pair is the only thing contrast has an opinion about. A palette can look balanced as a strip and contain no usable text pair at all.
The second failure mode is subtler: the palette is fine and the interface still is not, because the interface needs twenty colors and the palette has five. Border, hover, pressed, disabled, muted caption, subtle divider — each gets improvised at implementation time, usually by dropping opacity, and each of those improvisations is unmeasured.
Start with the pair that has to pass
Choose the background and the body text color first, before any brand color. They are the pair with the strictest requirement (4.5:1 at AA, 7:1 at AAA) and the pair that covers the most pixels, and once they are fixed everything else has room to move.
Give yourself headroom. A pair at exactly 4.6:1 leaves nothing for the caption that ends up slightly lighter or the surface that ends up slightly darker. Aiming at 7:1 for the main pair means the derived states still pass when they drift.
Then pick the surface — the card or panel color that sits on the background. It should be close enough to the background to read as a layer rather than a block, and far enough that its edge is visible without a border. In practice that is one or two steps on a ramp, and the same text color has to clear 4.5:1 against both.
Swatches are not enough
Expand each color into a tonal ramp — 50 through 950, the convention most design systems use — before you decide anything else. A ramp gives you the hover state (one step darker), the border (a mid step), the muted caption (a step that still clears 4.5:1), and the disabled fill, all from inside the hue family instead of from an opacity guess.
Build the ramp in a perceptual space. In HSL, stepping lightness in even increments produces a ramp that bunches up in the yellows and stretches in the blues, because HSL lightness is a geometric artefact rather than a perceptual quantity. In OKLCH the same even steps look even — which is what makes a 50–950 ramp usable as a set of interchangeable steps. See OKLCH vs HSL for the detail.
A useful property falls out of a well-built ramp: on most hues, steps 50–400 carry dark text safely and steps 700–950 carry light text safely, and the 500–600 band carries neither. Knowing where that band sits for your hue tells you immediately which steps can host text and which can only be surfaces.
Assign roles, then test the pairings
Name what each color does before you check anything: background, surface, text, muted text, border, primary, accent, and the three status colors — success, warning, danger. The names are what you will test against, and they are also what you will export as tokens.
Then measure the pairings an interface actually renders:
| Pairing | Needs | Because |
|---|---|---|
| Text on background | 4.5:1 | Body copy |
| Text on surface | 4.5:1 | Card and panel copy |
| Muted text on background | 4.5:1 | Captions are still text |
| Text on primary | 4.5:1 | Button labels |
| Primary on background | 3:1 | The button has to be findable |
| Border on surface | 3:1 | 1.4.11, if the border defines the control |
| Focus ring on both | 3:1 | 1.4.11 and 2.4.11 |
| Each status color on its own surface | 4.5:1 | Error text is text |
Grade every pairing in the palette builder
Beyond contrast
Contrast is criterion 1.4.3. Criterion 1.4.1 — "use of color" — is separate and is failed by palettes that pass contrast easily: a status system where red means failed and green means passed is unreadable to someone with deuteranopia if the two are at the same lightness, no matter how well each contrasts with the background.
Three habits cover it:
- Separate status colors by lightness as well as hue, so they stay distinct in greyscale.
- Pair every color-coded state with a second signal — an icon, a text label, a fill pattern.
- Run the palette through simulation and look for pairs that converge.
One more, easy to forget: do not rely on hue alone to distinguish adjacent series in a chart. Vary lightness across the series and the chart survives both color vision deficiency and being printed in greyscale.
A worked example
A light theme built the way described above, starting from the text and background pair:
| Role | Color | Against | Ratio |
|---|---|---|---|
| Background | #FFFFFF | — | — |
| Surface | #F4F5F7 | background | 1.1:1 |
| Text | #16181D | background | 17.6:1 |
| Muted text | #5B6070 | surface | 6.4:1 |
| Border | #C9CDD6 | surface | 1.5:1 |
| Primary | #1F5FD0 | background | 5.9:1 |
| Text on primary | #FFFFFF | primary | 5.9:1 |
Two things to notice. The border at 1.5:1 against the surface is
fine for a decorative divider and not fine as the only
thing marking an input field — that case needs a darker border, around
#8A90A0, to clear 3:1. And the primary at 5.9:1 does double
duty: it passes as a link color on the background and it carries white text
as a button fill, which is only possible because it sits below the 500–600
dead band rather than inside it.
What goes wrong
- Every color at mid-lightness. A palette of five colors all around L 55% is a palette with no text color and no background.
- Opacity as a substitute for a ramp.
opacity: .5produces an unmeasured color that changes with whatever is behind it. - Status colors chosen by hue only. Red, amber and green at the same lightness are one color to a large group of users.
- Testing the palette but not the theme. Dark mode is a second palette with its own measurements, not the same one inverted.
- Stopping at the swatch strip. Apply the palette to a real layout — a nav, a card, a form, a table — before committing it.
Checklist
- Text and background chosen first, with headroom above 4.5:1
- Every color expanded into a perceptual 50–950 ramp
- Roles named before tokens are exported
- All nine pairings in the table above measured
- Status colors separated by lightness, not only hue
- Every color-coded state carries a second, non-color signal
- Palette checked under deuteranopia, protanopia and tritanopia
- Light and dark themes measured independently
- Palette applied to a real layout before it is committed
Frequently asked questions
How many colors does an accessible palette need?
Fewer than most palettes have. One background, one surface a step off it, one text color, one primary and one accent will build a complete interface — five roles. What an accessible palette needs is not more hues but more steps per hue, so hover states, borders and disabled text come from a ramp rather than being improvised.
Can I use my brand color if it fails contrast?
Yes, on large surfaces. A brand color that fails 4.5:1 as a text color usually passes 3:1 as a button fill or a hero panel, and those are the places a brand color does its work. Derive a darker or lighter step from the same hue for the places it has to carry small text, and the two read as one color to a visitor.
Does an accessible palette have to look muted?
No. Contrast is a function of lightness, and chroma is nearly free — a fully saturated color at the right lightness passes as easily as a desaturated one. Palettes that end up muted usually got there by pushing every color toward mid-lightness, which is the range where both light and dark text fail.
What about color blindness — is contrast enough?
No. Contrast covers WCAG 1.4.3; distinguishability covers 1.4.1, which says color must not be the only means of conveying information. A red and a green at identical lightness pass no contrast test against each other and collapse into the same color under deuteranopia. Test the palette under simulation and pair every color-coded state with text, an icon or a pattern.