ChromaWell

Blog

How to Build an Accessible Color Palette

A concrete workflow — not just the WCAG numbers in isolation — for building a palette where every real text/background pairing in your product passes, on the first pass rather than a late-stage fire drill.

Most accessibility failures in production palettes don't come from ignorance of the 4.5:1 rule — teams usually know the number. They come from checking contrast on a handful of hero pairings during design review and never re-checking the dozens of derived pairings that show up once the palette gets applied across a real product: disabled states, placeholder text, hover overlays, chart legends, badge backgrounds. This is a workflow for avoiding that gap, not a restatement of the threshold itself (for the underlying formula, see WCAG contrast explained).

Start from role, not from hue

Before picking a single hex value, list the roles your palette needs to fill — not "blue" and "gray," but "primary action," "primary action, disabled," "destructive action," "success state," "body text on light surface," "body text on dark surface," "muted/secondary text," "border," "focus ring." Every role that pairs with text or carries meaning through color alone needs a contrast target attached to it before you pick a value, not after. This is also the seed of a real design-token system — see building a design token color system for how role-based naming (semantic tokens) survives a rebrand or a dark-mode variant far better than hue-based naming does.

Pick a base hue, then generate the ramp — don't hand-pick every step

Hand-picking six shades of blue by eye tends to produce a ramp with uneven visual steps and, more importantly, no guarantee that the specific shades you need for text-on-surface pairings actually clear contrast. A better sequence:

  1. Pick your base hue (the brand color, usually mid-lightness, mid-to-high saturation).
  2. Generate a full lightness ramp from it with the shades, tints & tones generator — this gives you evenly-stepped light-to-dark variants of the same hue rather than ad hoc guesses.
  3. Test every "text on surface" pairing your product actually needs against that ramp with the contrast checker, and note which specific ramp steps clear 4.5:1, which clear 3:1 only, and which fail outright.
  4. Assign ramp steps to roles based on what actually passed, not based on which step "looked right" — a step that fails contrast doesn't get promoted to a text-bearing role no matter how well it fits the brand mood.

This inverts the usual order (pick pretty colors, then discover some fail contrast) into one where failing colors get filtered out before they're ever assigned a role.

Don't rely on hue alone for meaning

A palette can pass every contrast check and still fail a color-blind user, because contrast checking is a luminance property and color vision deficiency is a hue-discrimination problem — the two are independent failure modes. Roughly 8% of men have some form of red-green color vision deficiency (protanomaly, deuteranomaly, or the rarer full dichromacy protanopia/deuteranopia); tritanopia (blue-yellow) is much rarer. If "success" and "error" states in your palette are distinguished only by red-versus-green hue at similar lightness, run them through the color blindness simulator — if the simulated protanopia or deuteranopia render looks ambiguous, add a second channel: an icon, a text label, a shape difference, or a deliberately large lightness gap between the two states. The full reasoning and more worked examples are in designing for color blindness.

Build in both directions: light mode and dark mode from the start

A palette that only gets tested against a white background will frequently break when the same tokens get reused on a dark surface — a muted gray that reads as adequately-contrasted secondary text on white can drop well below 3:1 on a dark navy surface. If your product ships (or will ever ship) a dark theme, test the palette against both surface colors from the start rather than retrofitting a dark theme later and discovering the whole secondary-text ramp needs new values. See dark mode color palette guide for the elevation and surface-layering rules that make this tractable instead of a second full palette from scratch.

Use harmony rules to keep the palette coherent while contrast rules keep it usable

Contrast and harmony are solving different problems and neither substitutes for the other. Once you know which lightness steps of your base hue clear contrast for text roles, use standard harmony relationships — complementary, analogous, or triadic hue relationships, generated from the color harmonies tool — to pick the *hues* for secondary and accent colors so the palette reads as a coherent system rather than an arbitrary grab-bag of accessible-but-unrelated colors. Color theory basics for developers covers the underlying hue-wheel math if you want the full derivation.

A minimal checklist before you call a palette done

  • Every text-on-surface pairing the product actually uses has been checked with real hex values, not approximated by eye.
  • Disabled, hover, and placeholder states — not just default states — have been checked; these are the most commonly-skipped pairings.
  • Any state or category distinguished by hue alone has a second, non-color signal (icon, label, shape, or a large deliberate lightness gap).
  • The palette has been simulated for protanopia, deuteranopia, and tritanopia at least once on the states that matter most (errors, warnings, required-field indicators).
  • If a dark theme exists or is planned, every token has been checked against the dark surface too, not just light.
  • UI component borders and focus rings — not just body text — have been checked against the 3:1 non-text threshold.

None of this is exotic; it's mostly discipline about checking derived pairings instead of stopping at the hero cases. The contrast checker and color blindness simulator do the actual measurement — the workflow above is really about remembering to run them on everything that needs it, not just the pairings that happen to be visible in a mockup.

A common failure that's worth naming: the "accessible enough" gray

A specific pattern shows up often enough to call out on its own: a team picks a mid-gray for secondary/muted text, checks it against white, sees it clears 4.5:1, and ships it as a single global "muted text" token. Two things then go wrong later. First, that same gray gets reused on a colored card background (say, a pale blue notification panel) where its contrast against the *actual* background is meaningfully lower than it was against pure white — because the check was only ever run against one surface. Second, if a dark theme gets added afterward, the identical gray hex is often reused verbatim rather than re-picked for the dark surface, and a gray tuned to sit at 4.5:1 against white can fall to 2:1 or worse against a dark navy background, since the arithmetic in the contrast formula is not symmetric in a way that lets one value serve both directions.

The fix is structural, not a one-off patch: treat "muted text on light surface" and "muted text on dark surface" as two separate tokens from the start, each independently checked against its actual surface, rather than one gray value reused everywhere and assumed safe. This is exactly the kind of role-based thinking a semantic token layer is built for — see building a design token color system for the primitive/semantic split that makes this maintainable instead of a spreadsheet of exceptions.

Testing at the size the text will actually render at

One more gap worth closing before calling a palette accessible: the 3:1 "large text" threshold only applies at genuinely large sizes (roughly 24px regular weight or 18.66px bold, per the point-based WCAG definition — see WCAG contrast explained for the exact numbers). It's easy to design a palette against a big, bold mockup heading, confirm 3:1 is enough, and then reuse that same "large text" color pairing on a 14px regular-weight caption elsewhere in the product, where it no longer qualifies for the relaxed threshold and needs the full 4.5:1 instead. Check contrast at the actual rendered size and weight the text will ship at, not at whatever size happened to be in the mockup when the check was run.

Get new tools & color guides by email

Occasional emails when a new tool, dataset, or in-depth guide ships. No spam, unsubscribe anytime.