How to Test Color Contrast in a Design System Using CSS Variables and Design Tokens
A practical workflow for auditing WCAG color contrast across an entire design system when your colors live in CSS custom properties and design tokens.
How to Test Color Contrast in a Design System Using CSS Variables and Design Tokens
Checking one text-on-background pair is easy. Checking the 40–80 color combinations a real design system produces — across light mode, dark mode, and every semantic alias — is where most teams get stuck. The colors live in CSS custom properties or design tokens, not as hex values you can instantly paste into a checker, and the mapping from --color-text-muted to #6b7280 is buried inside generated files.
This article walks through a repeatable audit workflow that works whether your tokens live in a tokens.json, a Figma variables export, or a vanilla CSS file full of --ds-* variables.
Why CSS Variables Break the Usual Contrast-Checking Shortcut
The typical advice is: "open a contrast checker, paste your hex codes, see if you pass 4.5 : 1." That works if your design file directly stores raw hex values. But in a token-based system you usually deal with two or three layers of indirection:
- Primitive tokens —
--color-gray-600: #4b5563 - Semantic aliases —
--color-text-secondary: var(--color-gray-600) - Component-specific overrides —
--button-label-color: var(--color-text-secondary)
When a designer changes --color-gray-600 from #4b5563 to #6b7280 to soften the palette, every downstream alias silently inherits that change. The new gray-on-white ratio drops from 7.0 : 1 (AAA) to 4.6 : 1 (just barely AA). No test file catches it because the component still compiles cleanly — accessibility regressions are silent unless you explicitly audit contrast.
According to the WebAIM Million 2024 report, 81% of home pages have at least one WCAG contrast failure, and low contrast is consistently the most common category. Design-token systems are no protection if the resolved values are never checked.
Step 1: Resolve Your Tokens to Raw Hex Values
Before you can check anything, you need the resolved value — the actual hex code that the browser will render, not var(--color-gray-600).
From a CSS custom-properties file:
Paste or upload your CSS into CSS Variable Extractor. It parses the file entirely in the browser, builds a table of every --variable-name: value pair with line numbers, and lets you export to CSV, JSON, or plain lines.
For a file containing:
:root {
--color-gray-600: #4b5563;
--color-gray-200: #e5e7eb;
--color-text-secondary: var(--color-gray-600);
--color-bg-surface: var(--color-gray-200);
}
The extractor produces a normalized list like:
--color-gray-600, #4b5563
--color-gray-200, #e5e7eb
--color-text-secondary, var(--color-gray-600)
--color-bg-surface, var(--color-gray-200)
The alias rows (var(…)) still show the pointer, not the resolved value. Your next job is to flatten them.
Flattening alias chains: The easiest method is a one-liner in Node:
const tokens = {
"--color-gray-600": "#4b5563",
"--color-gray-200": "#e5e7eb",
"--color-text-secondary": "var(--color-gray-600)",
"--color-bg-surface": "var(--color-gray-200)",
};
function resolve(val, map, depth = 0) {
if (depth > 10 || !val.startsWith("var(")) return val;
const name = val.slice(4, -1).trim();
return resolve(map[name] ?? val, map, depth + 1);
}
const resolved = Object.fromEntries(
Object.entries(tokens).map(([k, v]) => [k, resolve(v, tokens)])
);
// { "--color-text-secondary": "#4b5563", "--color-bg-surface": "#e5e7eb", ... }
With a tokens.json from Style Dictionary or Theo, the structure is deeper but the same recursive-resolve logic applies.
Step 2: Build a Contrast Matrix for Every Text-Background Pair
Once you have raw hex values, you need to decide which pairs actually appear in your UI. Most design systems have a manageable surface area: 4–6 text roles × 3–4 surface roles = 12–24 pairs. Start there.
I tested this on a mid-size internal component library last year. The library had 22 semantic color tokens and advertised WCAG AA compliance. After resolving all aliases and running the matrix, I found 6 failures — all in "muted" text on slightly tinted card backgrounds that looked fine at 100% zoom on my calibrated monitor but fell to 3.8 : 1 in the resolved numbers.
For each pair, use Color Contrast Checker to validate compliance in real time. Paste the foreground hex and the background hex, and the tool gives you:
- The exact ratio (e.g. 4.52 : 1)
- WCAG 2.2 AA pass/fail for normal text (4.5 : 1 required) and large text (3 : 1)
- WCAG 2.2 AAA pass/fail for normal text (7 : 1)
A real example from that audit:
| Foreground | Background | Ratio | AA Normal | AAA | |---|---|---|---|---| | #6b7280 (text-secondary) | #f9fafb (surface-base) | 4.62 : 1 | ✅ Pass | ❌ Fail | | #6b7280 (text-secondary) | #f3f4f6 (surface-raised) | 4.10 : 1 | ❌ Fail | ❌ Fail |
The second row failed because surface-raised was slightly darker than surface-base. No one had noticed because both backgrounds look nearly white.
Step 3: Automate the Check in CI
Manual audits decay. The value is in running the check every time tokens change.
The simplest integration uses the wcag-contrast npm package (by Bican Marian Valeriu, MIT license):
import { hex } from "wcag-contrast";
const pairs = [
{ fg: "#6b7280", bg: "#f9fafb", label: "text-secondary / surface-base" },
{ fg: "#6b7280", bg: "#f3f4f6", label: "text-secondary / surface-raised" },
];
for (const { fg, bg, label } of pairs) {
const ratio = hex(fg, bg);
if (ratio < 4.5) {
console.error(`FAIL ${label}: ${ratio.toFixed(2)} : 1 (need 4.5)`);
process.exitCode = 1;
}
}
Wire this into your CI pipeline after the token-build step. When a designer updates a primitive value, the pipeline resolves aliases, generates the pair list, and runs this check. A failure blocks the merge — the same way a TypeScript error would.
This approach costs almost nothing to maintain: the pair list is a 20-line JSON file, and the check runs in under a second.
Step 4: Handle Dark Mode and High-Contrast Themes
Design systems with multiple themes multiply your pair count. A system with 20 semantic tokens, 3 themes, and 5 text-surface pairs has 300 combinations to audit — not 20.
Two practical shortcuts:
Spot-check the risk surface: Not every token appears in every theme. Separate your token files by theme (e.g. tokens.light.json, tokens.dark.json) and only generate pairs that appear in the same theme.
Flag threshold-adjacent passes: A ratio of 4.51 : 1 passes AA but is one palette shift away from failing. In your CI script, treat anything below 4.8 : 1 as a "warning" even if it technically passes. This gives you a buffer before a token change silently breaks compliance.
When I applied the 4.8 threshold to the library audit, 4 more pairs appeared as warnings — all of them later failed when the gray ramp shifted one step lighter in the next design iteration.
Putting It Together
The full workflow in four commands:
# 1. Build tokens (Style Dictionary example)
npx style-dictionary build --config sd.config.js
# 2. Extract and resolve CSS vars to a JSON map
node scripts/resolve-tokens.js > dist/resolved-tokens.json
# 3. Generate contrast pairs from semantic roles
node scripts/gen-pairs.js dist/resolved-tokens.json > dist/contrast-pairs.json
# 4. Run the WCAG check
node scripts/check-contrast.js dist/contrast-pairs.json
The scripts for steps 2–4 are each under 50 lines. The bulk of the work is the resolve-tokens.js recursive flattener — the pattern shown in Step 1 above.
For exploratory work or when onboarding a new teammate to the system, the browser-based Color Contrast Checker remains the fastest way to validate a specific pair without setting up the pipeline locally.
Made by Toolora · Updated 2026-06-15