Color Pairings
The accessible foreground/background pairing system — which text token is WCAG-safe on which surface, for brand and service-tier colors, in both themes, enforced by a CI gate and portable to client themes.
A token name tells you a color's role, not whether it's legible on a given background. Picking a foreground and a background independently is how contrast regressions ship — and how they get patched at the consumer layer with hand-edited HEX instead of fixed at the root. This page is the contract that ends that: for every background family, the foreground that is WCAG-safe on it is fixed, documented, and gated.
The pairing set is data — tokens/contrast-pairings.json. One source of truth feeds three surfaces: the CI gate (scripts/validate-themes.js), the Contrast Compliance Storybook dashboard, and the matrix on this page. Add a pairing there; never hardcode a pair list in a component or a consumer.
The pairing question
What foreground is AA/AAA-safe on this background?
Answered per background family — three patterns cover the system:
1. Neutral surfaces, neutral text
On --page-*, --surface-*, and --background-* (the achromatic surfaces), pair the neutral text roles:
--text-primaryfor body and headings (clears AAA on every neutral surface, both themes).--text-secondaryfor supporting copy (AA).--text-mutedfor placeholder / disabled / decorative UI (AA-large, ≥3:1 — never for body).
2. Branded fills, on-color text
On a solid colored or branded fill (--background-brand-primary, and any service base hue used as a button fill), the safe foreground is the brand-agnostic on-color neutral, not a tinted text token:
--text-on-color-dark(white) on dark/saturated fills.--text-on-color-light(black) on light fills.
These are mode-invariant by design and are what Button/TabBar on-color variants consume. See Token Anatomy → color modifiers for why on-color is a complete neutral role distinct from the -on-light/-on-dark context modifiers.
3. Service-tinted surfaces
Service lines (brand/marketing/information/product/back-office → yellow/green/blue/purple/orange) are governed by ADR-011: the no-suffix token is a mode-invariant base hue (a swatch, not a foreground); readable color comes from the -on-light/-on-dark context variants.
The locked rule for text-bearing service regions:
- Use the pale
100surface step (--surface-service-{line}-light) with--text-service-{line}-on-light. This clears AAA in light on all five lines. - The mid-tone base step (
--surface-service-{line}/--background-service-{line}) is decorative — gated at AA only, for large/near-black labels, not small body copy. - For a solid service button, prefer pattern 2 (the
on-colorneutral) and verify, rather than a mid-tone fill with tinted text.
The standard
AAA (7:1) for body/small text where the surface affords it; WCAG AA is the enforced floor.
| Text | Aim | Enforced floor |
|---|---|---|
| Primary body on neutral / pale-service surfaces | AAA (7:1) | AA (4.5:1) |
| Colored brand & service text, on-color labels | AA (4.5:1) | AA (4.5:1) |
| Large/heading text, muted/decorative UI | AA-large (3:1) | AA-large (3:1) |
The gate fails any pairing below its floor. An AAA-aim body pairing that clears AA but lands under 7:1 is reported as a non-blocking "below AAA aim" note — visible, not blocking. This matches WCAG's own large-text allowance and keeps the gate honest about what it enforces.
The brand-color contract
Brik's brand color is Poppy (--color-poppy-500, #e35335) — a vibrant red that white text hits at 3.78:1: clears AA-large (3:1), not AA-normal (4.5:1). Rather than darken the brand to pass, BDS holds the vibrant color and gates it by role (ADR-015):
- Fills, headings, and accent/link/tab/breadcrumb affordances — gated AA-large (3:1). That is WCAG's bar for large/bold text and UI-component graphics, and the call every white-on-red CTA makes.
- Small body copy — never
--text-brand-primary; use--text-primary. Formerly advisory (the gate scores token pairings, not rendered font sizes), now CI-asserted bynpm run lint-brand-text, which flags--text-brand-primaryon--body-*-sized text (#1064). - Primary buttons at every size — no button label qualifies as WCAG large text (all ≤20px, semibold), so the vibrant fill is an owner-accepted AA-large / 1.4.11 risk, not a resolved pairing. ADR-016 ratified Option C (mint an AA-passing near-Poppy step) and explicitly declined a size floor — there is no size-gated fill in
Buttontoday; every size resolves--background-brand-primary.
The AA-passing near-Poppy step now exists — and is not wired up yet. --color-poppy-600 (#c94428) hits 4.84:1 on white: it clears AA-normal while staying visibly Poppy, which is exactly what ADR-015 and ADR-016 recorded as unavailable. It shipped with the 11-step scale in v0.152.0, unblocking ADR-016's Option C.
No Semantic token points at 600 today, so nothing has changed at the component layer. Repointing the on-color fills is #479. Don't reach for 600 directly in component CSS — Primitives never enter components; wait for the Semantic role.
Client brands are the mirror image, one contract. Brik chose vibrancy and relaxes to AA-large where the role allows. A client whose identity color can't clear AA on its own surfaces takes the opposite lever: tag a darker primitive brand-fill — the contrast-safe twin that fills --background-brand-primary / --text-brand-primary with an AA-passing value in-slot. Same rule ("brand text must be legible"), opposite mechanism (keep-the-hue-relax-the-tier vs. darken-the-slot).
The matrix
Measured against the shipped Brik light + dark themes. Generated by node scripts/validate-themes.js --emit-matrix — regenerate when token values change; do not hand-edit.
| Pairing | Target | Light | Dark |
|---|---|---|---|
| Body text on page | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Body text on surface | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Body text on background | AAA ≥7 | 17.22:1 AAA | 18.76:1 AAA |
| Secondary text on page | AA ≥4.5 | 12.63:1 AAA | 15.91:1 AAA |
| Secondary text on secondary surface | AA ≥4.5 | 11.29:1 AAA | 13.05:1 AAA |
| Muted text on page | AA-large ≥3 | 6.9:1 AA | 3.04:1 AA-lg |
| SegmentedControl inactive label on track | AA ≥4.5 | 15.39:1 AAA | 5.46:1 AA |
| Solid Tag / MultiSelect chip label on fill | AA ≥4.5 | 15.39:1 AAA | 5.46:1 AA |
| Secondary control label on secondary fill | AA-large ≥3 | 15.39:1 AAA | 3.43:1 AA-lg |
| SegmentedControl inactive hover label on tertiary fill | AA ≥4.5 | 13.05:1 AAA | 15.39:1 AAA |
| Disabled control label on disabled fill | AA-large ≥3 | 5.22:1 AA | 3.29:1 AA-lg |
| Neutral indicator label on subtle neutral fill | AA ≥4.5 | 8.15:1 AAA | 8.15:1 AAA |
| Solid neutral indicator label on fill | AA ≥4.5 | 12.08:1 AAA | 12.08:1 AAA |
| Inverse label on inverse fill | AA ≥4.5 | 17.22:1 AAA | 18.76:1 AAA |
| Faded fill-less control label | AA-large ≥3 | 3.32:1 AA-lg | 4.82:1 AA |
| Faded SegmentedControl segment label on track | AA-large ≥3 | 3.21:1 AA-lg | 2.66:1 ⚠ (#1741) |
| Brand text on page | AA-large ≥3 | 3.78:1 AA-lg | 5.55:1 AA |
| On-color label on brand fill | AA-large ≥3 | 3.78:1 AA-lg | 3.78:1 AA-lg |
| On-color content block on brand band | AA-large ≥3 | 3.78:1 AA-lg | 3.78:1 AA-lg |
| On-color label on brand fill (hover) | AA ≥4.5 | 6.23:1 AA | 6.23:1 AA |
| On-color label on brand fill (pressed) | AA ≥4.5 | 10.24:1 AAA | 10.24:1 AAA |
| Secondary Button label on hover/press fill | AA ≥4.5 | 13.05:1 AAA | 11.29:1 AAA |
| Outline Button label on hover/press fill | AA-large ≥3 | 3.38:1 AA-lg | 4.56:1 AA |
| Service brand — text on pale surface | AAA ≥7 | 8.4:1 AAA | 7.3:1 AAA |
| Service brand — text on mid-tone surface | AA ≥4.5 | 5.87:1 AA | 5.87:1 AA |
| Service marketing — text on pale surface | AAA ≥7 | 8.31:1 AAA | 7.69:1 AAA |
| Service marketing — text on mid-tone surface | AA ≥4.5 | 7.2:1 AAA | 7.2:1 AAA |
| Service information — text on pale surface | AAA ≥7 | 9.29:1 AAA | 6.89:1 AA |
| Service information — text on mid-tone surface | AA ≥4.5 | 4.59:1 AA | 4.59:1 AA |
| Service product — text on pale surface | AAA ≥7 | 13:1 AAA | 7.53:1 AAA |
| Service product — text on mid-tone surface | AA ≥4.5 | 4.85:1 AA | 4.85:1 AA |
| Service back-office — text on pale surface | AAA ≥7 | 13.87:1 AAA | 9.06:1 AAA |
| Service back-office — text on mid-tone surface | AA ≥4.5 | 4.79:1 AA | 4.79:1 AA |
| Service brand — inverse card text (light) | AA ≥4.5 | 8.59:1 AAA | — |
| Service brand — inverse card text (dark) | AA ≥4.5 | — | 7.3:1 AAA |
| Service marketing — inverse card text (light) | AA ≥4.5 | 8.48:1 AAA | — |
| Service marketing — inverse card text (dark) | AA ≥4.5 | — | 7.69:1 AAA |
| Service information — inverse card text (light) | AA ≥4.5 | 9.52:1 AAA | — |
| Service information — inverse card text (dark) | AA ≥4.5 | — | 6.89:1 AA |
| Service product — inverse card text (light) | AA ≥4.5 | 14.69:1 AAA | — |
| Service product — inverse card text (dark) | AA ≥4.5 | — | 7.53:1 AAA |
| Service back-office — inverse card text (light) | AA ≥4.5 | 16.34:1 AAA | — |
| Service back-office — inverse card text (dark) | AA ≥4.5 | — | 9.06:1 AAA |
| Service brand — inverse card fill (light) | AA-large ≥3 | 8.59:1 AAA | — |
| Service brand — inverse card fill (dark) | AA-large ≥3 | — | 7.3:1 AAA |
| Service marketing — inverse card fill (light) | AA-large ≥3 | 8.48:1 AAA | — |
| Service marketing — inverse card fill (dark) | AA-large ≥3 | — | 7.69:1 AAA |
| Service information — inverse card fill (light) | AA-large ≥3 | 9.52:1 AAA | — |
| Service information — inverse card fill (dark) | AA-large ≥3 | — | 6.89:1 AA |
| Service product — inverse card fill (light) | AA-large ≥3 | 14.69:1 AAA | — |
| Service product — inverse card fill (dark) | AA-large ≥3 | — | 7.53:1 AAA |
| Service back-office — inverse card fill (light) | AA-large ≥3 | 16.34:1 AAA | — |
| Service back-office — inverse card fill (dark) | AA-large ≥3 | — | 9.06:1 AAA |
| Positive/success text on surface | AA ≥4.5 | 8.48:1 AAA | 7.31:1 AAA |
| Positive/success text on page | AA ≥4.5 | 8.48:1 AAA | 7.31:1 AAA |
| Solid positive Badge label on fill | AA ≥4.5 | 6:1 AA | 7.31:1 AAA |
| Solid error Badge label on fill | AA ≥4.5 | 4.95:1 AA | 6.03:1 AA |
Every recommended pairing clears its WCAG target in both themes, with one tracked exception: the faded Chip secondary label measures 2.34:1 in dark mode, because dark --background-secondary is a mid-grey that gives its label only 3.43:1 even while enabled. That mid-grey is deliberate — the token carries two opposite foregrounds, and no grayscale step clears AA for both — so the fix is Chip adopting the disabled token swap rather than a token change (#1701). The vibrant brand-primary rows — brand text and the white on-color label on the brand fill — clear AA-large (3:1) per the brand-color contract above. One service pairing (information · pale · dark, 6.89:1) misses the AAA aim by a hair while comfortably exceeding AA.
The three Faded … rows are disabled states. A faded pairing is scored after compositing both its foreground and its fill over the page at --state-disabled-opacity, because opacity moves the label and the fill toward the same backdrop. Scoring the raw token pair instead reported a fade as safe when it was not — see Interaction States for which controls fade and which swap tokens.
The dark-mode service gap
Historically, ADR-011's dark-mode softening (context text shifts one tier lighter while service surfaces stay fixed-light) dropped service text below AA in dark mode. That active gap is largely closed for the recommended pairings — brik-bds#865 pinned the dark service context tokens to their darkest primitive (stop 950), and #827 darkened the light-mode primitives so even the mid-tone step clears AA.
What remains is the non-recommended anti-pattern: a white label on a mid-tone service base fill in dark mode (marketing/brand), which is sub-AA. The foundation steers away from it (use pattern 2 or 3). Service pairings in the dataset carry a darkException guard linked to #823: if a future token change pushes a dark service pairing below AA, the gate reports it as a tracked exception rather than hard-failing — the gap stays visible and owned, never silently passing.
Client themes inherit this for free
Pairing safety is relationship-based, not per-HEX. A client theme (theme-{client}.css, [data-audience]/[data-service] scope binding, registerClientTheme()) that fills its ramp per the documented step-roles — pale 100 for text-bearing surfaces, 950 for context text, on-color neutrals for solid fills — inherits AA-safety without anyone hand-tuning a hex to pass a checker.
Validation comes from the same source: the Contrast Compliance dashboard probes every registered theme — including client themes — against this exact pairing set via getComputedStyle. A new client ramp is audited against the full matrix the moment it's registered.
Enforcing it
The gate runs in the validate script (pre-commit), the release workflow (pre-publish), and pr-checklist.sh:
npm run contrast-gate # node scripts/validate-themes.js — exits 1 below the AA floor
node scripts/validate-themes.js --emit-matrix # regenerate the table aboveTo add or change a pairing, edit tokens/contrast-pairings.json and regenerate the matrix. The gate, the dashboard, and this page stay in lockstep because they read the same file.
Related
- Color — the canonical token registry these pairings draw from
- Token Anatomy — the four color-modifier constructs (
on-colorvs-on-light/-on-darkvs-inversevs tone) - Client Themes — scope binding + the theme registry the dashboard probes
- ADR-011 — the service-line value model the service pairings follow
- ADR-015 — brand-primary holds vibrant Poppy, gated AA-large
- ADR-016 — small-primary-button contrast remedy (staged)
brand-filltier — the client-theme mechanism for brands that darken in-slot