Contrast check.
WCAG 2.x relative-luminance contrast ratio between any two colors, with pass/fail badges for AA and AAA at normal and large text sizes. Live preview shows actual rendered text. The accessibility check that should run on every text-on-color pair you ship.
| Level | Normal text | Large text (18pt+/14pt+ bold) |
|---|---|---|
| AA · 4.5:1 / 3:1 | pass | pass |
| AAA · 7:1 / 4.5:1 | pass | pass |
color: #0a0a0a; background: #ffffff; /* contrast: 19.80:1 */
Relative luminance, explained.
The WCAG 2.x contrast ratio formula has two stages. First, compute relative luminance for each color: take the sRGB hex code, convert each channel to the [0, 1] range, apply the inverse gamma function (linearise), then take a weighted sum where the weights reflect the eye's sensitivity to red, green, and blue. The standard coefficients — 0.2126 R + 0.7152 G + 0.0722 B — are derived from the CIE 1931 luminosity function and are the same coefficients used in HDTV and HDR encoding. Green dominates because the eye's photopic-response peak sits around 555nm, which is in the green part of the spectrum.
Second, take the ratio: (L_lighter + 0.05) / (L_darker + 0.05). The constant 0.05 is the "viewing flare" assumption — a small offset that approximates the contribution of ambient light reflected off a typical screen. Without the flare term, dark-on-dark pairs would produce arbitrarily large ratios as both luminances approach zero, which contradicts the perceptual experience of pure black on slightly-off-black being almost invisible.
The result is a number between 1.0 (identical colors, no contrast) and 21.0 (pure black on pure white). WCAG picks specific thresholds along this range: 3.0 for large text or graphical elements, 4.5 for body text, 7.0 for AAA-grade body text. The thresholds are calibrated against testing with low-vision users, including the 8% of men with red-green color deficiency. They're not arbitrary — they correspond to real perceptibility cutoffs at typical viewing distances.
The formula is well-known but nontrivial to implement correctly. The most common error is to skip the linearisation step and compute luminance directly from sRGB values, which biases the result toward green-heavy colors. The second most common is to forget the +0.05 offset, which produces dramatically wrong ratios for dark pairs. Production tools — axe-core, Lighthouse, this calculator — all run the spec implementation; if you're computing contrast in your own code, copy from a vetted library rather than rolling it.
Why WCAG 2 has known holes.
The WCAG 2 contrast formula was published in 2008 and reflects a 2000s-era understanding of perceptual color. It works well for the typical case — black-ish text on white-ish backgrounds — and breaks down at extremes. Dark text on dark backgrounds reports as failing the 4.5:1 threshold even when the pair is comfortably readable; very-light pairs (#fafafa on #ffffff) report as fine even when they're nearly invisible.
APCA — Accessible Perceptual Contrast Algorithm — was proposed by Andrew Somers in 2019 as the successor for WCAG 3.0. APCA models contrast as a Lightness Contrast (Lc) value computed via a spatial-frequency-aware algorithm that takes font weight, font size, and stem thickness into account. The result is a unitless number from 0 to about 108, with thresholds tuned to specific use cases: 90+ for body text on critical content, 75+ for content text, 60+ for fluent text, 45+ for large text, 30+ for non-content elements. APCA correctly identifies the dark-on-dark and white-on-white edge cases that WCAG 2 mishandles.
The transition is slow because WCAG 3.0 remains a W3C working draft and the standardisation process for accessibility specs runs in years, not months. Some forward-looking design systems — Adobe Spectrum, the GOV.UK design system, several Tailwind extensions — already validate against APCA in addition to WCAG 2.x. The pragmatic position: ship against WCAG 2.2 AA because that's what auditors require, and additionally check critical pairs in APCA so you're not surprised when the standard updates.
A worth-knowing detail: the WCAG 2 algorithm produces the same ratio regardless of which color is foreground vs background — a 4.5:1 light-on-dark scores the same as 4.5:1 dark-on-light. APCA distinguishes the two because the perceptual experience is different. This is one of several ways APCA more closely matches what the eye actually does.
Three patterns that fail audits.
Mid-tone-on-mid-tone. The single most common contrast failure is body text in a "soft grey" (#666 or #888) on a slightly-off-white background (#fafafa or #f5f5f5). The combination feels gentle and minimal in the design comp; in practice it scores 4.0:1 or below and fails AA. The fix is rarely to change the background — it's to darken the text. Anything from #5a5a5a on #ffffff (about 5.7:1) up satisfies AA reliably.
Brand-color-on-brand-tint. A blue brand at #2b3a67 on a 5%-tinted-blue surface looks like the brand's own ecosystem in mockups. Run the contrast check and it likely fails — the tinted background is too close to the text color for comfortable reading. The remedy is either to use the brand color for accents and keep text on neutral backgrounds, or to use a pure dark token (often the 800 stop on the brand's tonal scale) for body text and reserve the brand-color itself for buttons and links.
Hover/disabled state contrast. Designers tend to validate the default state and forget the variants. A button that passes AA in its default state often fails on hover (typically darkened by 10%, which hurts contrast) or on disabled (typically lightened by 50%, which definitely fails). The fix is to validate every state your component supports — primary default, primary hover, primary active, primary disabled, primary on dark surface, primary on light surface — and adjust the disabled state in particular, which most teams ship at opacity: 0.5 without checking what that does to the math.
Contrast is not the only check.
Roughly 8% of men and 0.5% of women have some form of color-vision deficiency — most commonly red-green confusion (deuteranopia or protanopia). For these users, a UI that conveys information through color alone — green check vs red x without text labels — is partially or fully unusable. Contrast ratio is necessary but insufficient: high-contrast pairs can still be invisible to color-blind users if the contrast is conveyed by hue rather than lightness.
The defensive pattern is "color is decoration; meaning lives elsewhere". Pair every color-coded signal with a redundant signal — an icon, a label, a position cue, a shape. Status indicators get an icon shape (✓, ✗, ⚠) in addition to color. Form-validation errors get text alongside the red border. Charts get pattern fills (stripes, dots, hatching) in addition to color, or use distinct lightness levels rather than just hue.
Tools that simulate color vision deficiency are available in browser dev tools (Chrome's "Emulate vision deficiencies" feature), as standalone applications (Sim Daltonism on macOS, Color Oracle cross-platform), and as Figma plugins (Stark, Able). Run any UI through deuteranopia simulation before shipping.
For data visualisation specifically, Cynthia Brewer's ColorBrewer (2003) palettes — sequential, diverging, and qualitative scales designed to remain distinguishable in CVD simulations — remain the gold standard. Most chart libraries (Plotly, Vega, D3) bundle ColorBrewer palettes; reach for them rather than picking colors by eye.
Catch failures before they ship.
Manual contrast checking is fine for one-off pair validation. For a real product surface with hundreds of color pairs, automation is the only sustainable approach. Three integration points cover most cases.
Design time. Figma plugins like Stark, Able, Contrast, and Color Contrast Analyzer score contrast in real time as designers select elements. A failing pair shows as red in the layers panel before the design ships to engineering. This is the cheapest place to catch issues — fixing in Figma takes seconds, fixing in production takes a release cycle.
Build time. axe-core (Deque), Lighthouse-CI (Google), and pa11y all run contrast audits as part of automated test pipelines. Setting them up to fail builds on contrast violations is one configuration file in most CI systems. Storybook + the @storybook/addon-a11y package surfaces issues in the component library directly.
Runtime. CSS Color Module Level 6 proposes color-contrast() — a function that picks the best foreground from a list of candidates against a dynamic background. Until that ships universally, runtime contrast adaptation requires JavaScript: compute the luminance of the background, pick light or dark text accordingly. Many media-heavy interfaces (photo grids, video walls, social feeds) use this pattern.
For shipped product, the practical workflow is: design with a contrast-aware Figma plugin, validate with axe-core in CI, treat failing contrast as a build break, fix at the token level rather than per-component, and audit the production site quarterly with Lighthouse to catch regressions from new content.
Contrast applies to non-text elements too.
WCAG 2.1 introduced Success Criterion 1.4.11 — Non-text Contrast — which extends the contrast requirement to "user interface components and graphical objects" at a minimum 3:1 ratio. This covers form-field borders, button outlines, focus indicators, icons that convey meaning, and chart elements that distinguish data. The criterion is widely overlooked because it's newer (added in WCAG 2.1, June 2018) and because designers tend to think of accessibility as a text-only concern.
The most common failures: faint borders on form fields (a 1px #e5e5e5 border on a white background is 1.4:1, well below 3:1), low-contrast focus rings (the default browser ring is fine; the custom ring designers add to "polish" it usually fails), and chart axes drawn in light grey on white (also typically below 3:1).
Focus indicators deserve special attention. WCAG 2.4.7 requires that the focused element be visually distinguishable; in practice, this means a focus ring with at least 3:1 contrast against the surrounding background. Many design systems strip the default browser focus ring and replace it with a fainter custom ring that fails. The fix is to keep the focus ring high-contrast — typically a 2px outline in the brand color or a high-contrast neutral, with at least 2px offset so it doesn't overlap component borders. The :focus-visible pseudo-class lets you keep keyboard focus rings prominent while suppressing them for mouse interactions.