Skip to main content

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.

Published
#color-contrast #wcag #css-variables #design-tokens #accessibility

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:

  1. Primitive tokens--color-gray-600: #4b5563
  2. Semantic aliases--color-text-secondary: var(--color-gray-600)
  3. 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