§
§ · journal

Color system, WCAG AA across modes.

How to build a color token system that passes WCAG 2.2 AA in light and dark mode without flattening brand personality.

Two layers, AA hard-fail.

A compliant 2026 color system uses two token layers. Primitive tokens are raw HSL values (moss-50 through moss-900). Semantic tokens (text-primary, surface-elevated, accent-positive) map to primitives. Components reference semantic tokens only. Light and dark variants swap the semantic-to-primitive map; components do not change. WCAG 2.2 AA thresholds: 4.5:1 normal text, 3:1 large text (18.66px regular or 14px bold), 3:1 UI components and borders. AAA adds 7:1 for normal body text. Automate testing with axe-core in unit tests plus Lighthouse in CI plus a Figma plugin during design. Hard-fail the build on any AA miss. When a brand color fails AA, choose one of three paths: restrict to large-text or non-text-UI roles, place on a darker surface, or negotiate a 10 percent saturation or lightness shift with the brand team. Use WCAG 2.2 contrast for legal compliance; use APCA as a stricter perceptual sanity check.

Three ratios. Two roles.

WCAG 2.2 (Web Content Accessibility Guidelines) defines contrast requirements in Success Criterion 1.4.3 (Contrast Minimum) at the AA level and Success Criterion 1.4.6 (Contrast Enhanced) at the AAA level. AA requires 4.5:1 contrast for normal text against its background, and 3:1 for large text. AAA requires 7:1 normal and 4.5:1 large.

The definition of "large" matters precisely. Large text equals 18 point or larger (24px), or 14 point bold or larger (about 18.66px). Below those thresholds, the stricter 4.5:1 rule applies. Designers who specify a 16px subhead in bold should not assume the 3:1 large-text exception covers them; the bold weight needs to be 14pt (18.66px) to qualify.

WCAG 2.2 also covers UI components and graphical objects in Success Criterion 1.4.11 (Non-text Contrast): 3:1 contrast for the visual indicators of UI components (button borders, input borders, focus rings, icon strokes) against their adjacent backgrounds. This is widely under-tested; many design systems pass body text contrast but fail UI component contrast on disabled states or subtle backgrounds.

Compliance target for a 2026 production system: AA across all surfaces, AAA on body text where the brand allows. AAA is a meaningful upgrade for long-form reading (journal posts, documentation), but the ratio constraint sometimes forces a brand to give up its accent color on white backgrounds, which is rarely acceptable. Most production systems land at AA across the board, AAA on body, with occasional documented exceptions.

Primitives, then semantics.

The two-layer token model separates raw color values from their meaning. Primitive tokens are the palette: moss-50, moss-100, moss-200, through moss-900, plus the same for ink (neutrals), cream (lighter neutrals), amber, ruby, sky. Each primitive has a fixed HSL value that never changes. The names describe color, not role.

Semantic tokens are the roles: text-primary, text-secondary, text-muted, text-accent, surface-page, surface-elevated, surface-overlay, border-subtle, border-default, border-strong, accent-positive, accent-warning, accent-negative, focus-ring. Each semantic token maps to one primitive token. In light mode, text-primary maps to ink-900; in dark mode, text-primary maps to cream-50. The component code references text-primary; it does not know which mode it is in.

Reference: W3C Design Tokens Community Group format, Material 3 token reference, Shopify Polaris tokens. The W3C draft spec is the right starting point for a new system in 2026.

The benefit of the two-layer split is theming. Light, dark, high-contrast, and brand-customized themes are all variations of the semantic-to-primitive map, not of the components. Adding a new theme is a new mapping file, not a rewrite. The cost is one layer of indirection during design (a designer sees "text-primary" not "ink-900"), which is mild and quickly familiar.

sRGB luminance, two formulas.

WCAG 2.2 contrast is computed in two steps. First, calculate the relative luminance of each color: linearize the sRGB R, G, B values, then combine as L equals 0.2126 times R plus 0.7152 times G plus 0.0722 times B. The weighting reflects human eye sensitivity to red, green, blue. Second, compute the ratio: contrast equals (L1 plus 0.05) divided by (L2 plus 0.05), where L1 is the lighter color's luminance and L2 is the darker. The 0.05 offset prevents division-by-zero on pure black.

The math gives ratios from 1:1 (identical colors, no contrast) to 21:1 (pure black on pure white, maximum). 4.5:1 is the AA threshold for normal text; 7:1 is the AAA threshold. Reference implementations: SAPC-APCA repository includes WCAG 2.2 contrast as one of its calculators; W3C's relative luminance definition documents the formula.

APCA (Advanced Perceptual Contrast Algorithm) is a candidate for CSS Color Module Level 4 and is more perceptually accurate than WCAG 2.2. APCA accounts for font size, weight, and the spatial properties of vision in ways WCAG 2.2 does not. A pair of colors can pass WCAG 4.5:1 but be perceptually too low contrast for small body text; APCA catches this.

Practical advice: use WCAG 2.2 contrast for legal and regulatory compliance (Section 508 in the US, EAA in Europe, ADA case law). Use APCA as a stricter design-review check, especially for body text on tinted backgrounds where WCAG passes look perceptually weak. Do not replace WCAG with APCA yet; APCA is not WCAG-required and may not be for another standards cycle.

Design, dev, CI: three checks.

Three checks at three phases. Phase one, design: Stark or Contrast Figma plugins flag low-contrast pairings during the design phase before any code exists. Stark in particular shows the contrast ratio overlay on hover and lets the designer adjust before handoff.

Phase two, development: axe-core in unit tests catches issues during component development. axe-core integrates with React Testing Library, Jest, Vitest, and Cypress. A standard pattern: a single test per component that renders the component and runs axe.run(); any AA failure fails the test. The result is that contrast problems show up in the developer's local test run, not in production.

Phase three, CI: Lighthouse in CI runs accessibility audits on the deployed pages. Lighthouse uses axe under the hood and flags contrast issues at the page level (catching combinations missed in component tests). Lighthouse CI integrates with GitHub Actions and lets you set accessibility-score thresholds (typically 95 or higher).

The build should hard-fail on AA contrast failures for production components. Exceptions (a component intentionally below AA for reasons like brand alignment or a stylistic accent on a non-critical element) are allowed but require documented reasoning in the component file and an entry in an accessibility exception log. The log is reviewed quarterly; exceptions tend to creep, and the review catches drift.

Three paths when it fails.

A common scenario: the brand's signature accent color is #4FB6D9 (a mid-saturation cyan). On white background, it scores 3.1:1 contrast, which fails AA for normal text (4.5:1 needed) but passes for large text and UI components (3:1 needed). The brand team insists on this exact color. What to do.

Path one: restrict the color's role. The cyan is allowed only on large text (24px or 18.66px bold and above) and on non-text UI elements (button borders, focus rings, icons larger than 24px). Body text uses a different semantic token (text-primary, mapped to a higher-contrast neutral). The brand color still appears in the product, but in the roles where 3:1 is sufficient.

Path two: use the color on a darker surface. The cyan on a near-black surface (ink-900) scores 8.5:1, which passes AAA. Pages that need the brand accent on body text use a dark-mode-style surface even in nominally light contexts. The product becomes more visually layered, which can suit some brands and clash with others.

Path three: negotiate a small shift. Most brand books have plus or minus 10 percent latitude in saturation or lightness. A 10 percent darker version of #4FB6D9 lifts the contrast to 4.2:1; a 15 percent darker version hits 4.6:1 (AA pass). The shift is small enough that no non-designer notices, large enough to clear compliance. The brand team has to agree, and the new value gets recorded in the brand book as the canonical web swatch. The print swatch (where contrast does not apply) can stay original. Related reading: Brand book vs design system, Modular type scale guide, Motion tokens in Tailwind.

Six answers.

What WCAG contrast thresholds apply to a 2026 design system?

WCAG 2.2 AA: 4.5:1 normal text, 3:1 large text (18.66px regular or larger, 14px bold or larger), 3:1 UI components and borders. AAA adds 7:1 for normal text. Most brands target AA across the system, AAA on body text.

What does a two-layer token system look like?

Primitive tokens: raw HSL values like moss-500, amber-700. Semantic tokens: text-primary, surface-elevated, accent-positive. Components reference semantic tokens only. Light and dark variants swap the semantic-to-primitive map; components don't change.

Should I use WCAG 2.2 contrast or APCA?

WCAG 2.2 contrast for compliance, since it's the legal and regulatory standard. APCA as a stricter perceptual check during design review. APCA is a CSS Color 4 candidate but not yet WCAG-required, so don't replace WCAG with it yet.

How do I automate contrast testing in CI?

axe-core in unit tests plus Lighthouse in CI plus a Figma plugin like Stark or Contrast during design. Hard-fail the CI build on any AA failure for production components. Allow exceptions only with documented reasoning per component.

What if our brand color fails AA contrast?

Three options. (a) Restrict it to large-text or non-text-UI roles where 3:1 suffices. (b) Use it on a darker surface where contrast passes. (c) Negotiate a 10 percent saturation or lightness shift with the brand team. Most brand books have that latitude.

How do dark-mode colors integrate with the same system?

Use semantic tokens that swap their primitive mapping. Light mode: text-primary maps to ink-900. Dark mode: text-primary maps to cream-50. Components don't know which mode they're in; the token layer handles it.

Pass AA without flattening brand.

We build two-layer token systems, run axe-core in CI, audit dark mode parity, and find the brand-versus-contrast path that keeps both teams happy. Scoped quote in 48 hours.

Published .