Color contrast in design systems: why your tokens keep failing WCAG
Most design systems ship a color ramp that passes contrast in Figma and fails in production. Here is why it happens, and the token patterns that prevent it.
A design lead I was helping a few weeks ago sent over her team's color ramp. Eleven steps of gray, eleven steps of brand blue, eleven of each semantic color. Beautiful spec sheet. Every swatch had a contrast ratio printed next to it. She wanted to know why her engineering team kept opening tickets that said "this text fails WCAG" against components she had explicitly designed to pass.
We pulled up the product. Her caption text — gray-500 on gray-50 — was 3.9:1. The Figma file said 4.6:1. Someone had updated gray-50 from #FAFAFA to #F4F4F5 six months ago to make cards "feel softer" and nobody re-checked the pairings. Nine components inherited the change. Three of them were behind a feature flag at the time. Two were used on the marketing site.
This is the most common contrast failure mode I see, and it has nothing to do with anyone being careless. It is the design system equivalent of a dependency upgrade that breaks a function call three layers down. The tokens look fine in isolation. The pairings break silently.
What WCAG actually requires
There are three contrast success criteria that almost every web product has to think about.
1.4.3 Contrast (Minimum) — Level AA. Body text needs a 4.5:1 contrast ratio against its background. Large text (18pt regular, 14pt bold, or larger) needs 3:1. This is the one most teams have heard of.
1.4.11 Non-text Contrast — Level AA. UI components and meaningful graphical objects need 3:1 against adjacent colors. This covers form input borders, focus indicators, icon-only buttons, chart elements, anything a user needs to perceive to operate the interface. Most design systems fail this one without realizing it — a 1px border at 2.4:1 is easy to draw, hard to see, and never shows up in a text-only contrast audit.
1.4.6 Contrast (Enhanced) — Level AAA. 7:1 for body text, 4.5:1 for large text. Not required for AODA, ADA, or EAA conformance, which all target AA. Worth knowing because some procurement questionnaires ask for it.
Notice what is not in this list: a token-level requirement. WCAG cares about the rendered pixel pair on screen. Your tokens are an implementation detail. The audit does not care whether text-secondary passes against surface-default in your style guide if the actual <p> on the actual page ends up at 3.9:1.
Why design systems fail at contrast
Every failing system I have looked at falls into one of four buckets.
The ramp was generated for aesthetics, not for pairing. Someone picked a brand hue, ran it through an HSL ramp generator, and shipped 11 steps. Step 5 looks great next to step 1. Step 4 next to step 1 fails. Nobody wrote down which pairs are safe, so designers and engineers pair whatever looks balanced.
Semantic tokens hide the pairing. text-muted on surface-subtle reads like an obvious combination, but neither token tells you what the contrast is. Six months later someone updates surface-subtle to be 4% darker because a designer thought it was too washed out. text-muted did not change. The combination broke.
Brand color sits on white. Most brand palettes have a single "primary" color that gets used on buttons, links, focus rings, and small badges. That one hex value has to clear 4.5:1 against white for link text, 3:1 against white for icon-only buttons, 3:1 against the button background for focus ring contrast against the button. Picking a primary hue that satisfies all of those constraints is hard. Most brand palettes were not designed with those constraints in mind.
Dark mode is an afterthought. A team builds a clean light theme that passes contrast, then derives dark mode by inverting the lightness values. The semantic pairings invert too — text-primary on surface-default was 12:1 in light mode and is 4.1:1 in dark mode because the inverted neutrals do not behave the way the original ramp did.
Patterns that actually work
I do not think the answer is "be more careful." Design systems get used by dozens of people who never read the contrast appendix. The system has to enforce contrast structurally.
Pair tokens, not single tokens. Instead of asking designers to choose text-primary and surface-default separately, ship surface-default with a documented text-on-surface-default that is guaranteed to pass. If a designer needs text-muted on that surface, ship text-muted-on-surface-default as its own token with a tested pairing. This sounds verbose. It is. It also means a token rename in your light theme cannot quietly break contrast in production because every text token is bound to a specific background.
Generate your ramp with contrast targets, not lightness curves. Tools that pick ramp steps by perceptual lightness (OKLCH-based generators are popular right now) can be configured to enforce contrast against an anchor background. Pick your anchor backgrounds first — typically your lightest surface and your darkest surface — and generate your text ramp so that step N always passes 4.5:1 against the anchor. If a designer changes the anchor, the whole text ramp regenerates. Nothing drifts.
Treat non-text contrast as a first-class token. Borders, focus rings, dividers, and chart strokes all need 3:1 against whatever they sit on. Most design systems lump these into "neutral-300" and ship a value that looks subtle on white. It is subtle because it fails contrast. Split the role: a border-default token at 3:1 against your default surface, a border-subtle token explicitly documented as decorative and not WCAG-compliant for meaningful UI.
Audit on every PR, not every release. A design token PR that updates surface-subtle should not get merged without checking every pair that references it. This is where automated scanning earns its keep — running axe-core or any rule engine against a rendered Storybook on every PR catches the cascade before it ships. We talk about this kind of CI integration in our post on accessibility in GitHub Actions.
What a 4.5:1 system actually looks like
A working contrast-safe color system has, roughly, three things you can describe in one screen.
A small set of anchor backgrounds. Two or three surfaces in light mode, two or three in dark mode. Everything else is described in relation to these.
A text ramp tested against each anchor. Three or four steps per anchor — primary text, secondary text, tertiary/muted, disabled — with the contrast ratio documented next to each. If a step fails 4.5:1 against any of its allowed backgrounds, it does not ship.
Named, paired UI tokens. focus-ring-on-button-primary is a real token because focus rings on primary buttons have to clear 3:1 against the button fill. border-input-on-surface-default is a real token because input borders on the default form surface have to clear 3:1. These names are ugly. They are also testable.
The system then needs one more thing: a CI check that fails the build if any rendered pair violates the constraint it claims to satisfy. This is the difference between a design system that documents contrast and one that enforces it. Documentation drifts. Enforcement does not.
What inklu does with contrast
inklu's scanner runs axe-core 4.11 plus 50+ proprietary rules against your web app or repo. Contrast violations — both 1.4.3 and 1.4.11 — come back with the offending pair, the computed ratio, and an AI-generated pull request that proposes a fix. For a design system change that cascades to 40 components, this means you get one PR per fix grouped by token reference, not 40 individual issues to triage. We support HTML, CSS, JSX, and SCSS, so a token defined in your SCSS variables file and consumed by a JSX component will be traced end-to-end.
Source code is purged after each scan. We do not store your repo. The only thing that persists is the PR sitting in your GitHub queue, which you accept or close like any other PR.
If you are auditing a design system right now and want a second pass, book a demo at inklu.io or email hello@inklu.io. We will scan a representative page, send the violations, and walk through the token-level fixes. The conversation tends to take 30 minutes and produces a list you can hand to the design system owner the same day.