DEV Community

Cover image for Color Contrast Ratios: The Reference Tables and Edge Cases Every Frontend Engineer Should Memorize
Tea-sip for Lizely

Posted on

Color Contrast Ratios: The Reference Tables and Edge Cases Every Frontend Engineer Should Memorize

When a design reviewer rejects a button because "the text doesn't have enough contrast," the conversation usually stalls at a vague sense that something looks washed out. The actual rule behind that judgment is a deterministic formula, a small set of thresholds, and a handful of traps that make naive implementations fail at audit time. This article is a working reference: the numbers, the calculation, the failure modes, and the verification loop an engineer can paste into a team's review checklist.

The Two Numbers That Drive Every Decision

Every contrast decision collapses to two values:

  1. The relative luminance of the foreground color (the text or icon).
  2. The relative luminance of the background color (the surface it sits on).

The W3C defines relative luminance as a piecewise function that linearizes an sRGB channel value, then weights the three linear channels. The exact definition lives in the W3C Web Content Accessibility Guidelines 2.2 specification, and the supporting background on sRGB gamma is documented on Wikipedia's sRGB article. The formula is:

  • For each sRGB channel v in [0, 1]: if v ≤ 0.04045, use v / 12.92; otherwise use ((v + 0.055) / 1.055) ^ 2.4.
  • Relative luminance L = 0.2126 * R + 0.7152 * G + 0.0722 * B.

The contrast ratio is then (L1 + 0.05) / (L2 + 0.05), where L1 is the lighter of the two luminance values and L2 is the darker one. The result is a number from 1:1 (identical colors) to 21:1 (black on white).

Two things are worth pinning down before moving on. First, the 0.05 offset is there to prevent division by zero and to keep ratios finite; it is not a fudge factor you can tune. Second, because luminance is channel-weighted, two colors with the same hex brightness can produce very different ratios if their hue distribution differs. A "medium gray" with equal RGB is not the same as a medium gray tinted toward red or blue.

The Threshold Table You Actually Need

The WCAG thresholds are simple, but engineers regularly confuse which level applies to which element. The reference table below is the version worth memorizing:

Element type Minimum (AA) Enhanced (AAA) Non-text (AA)
Body text (< 18 pt regular or < 14 pt bold) 4.5:1 7:1 n/a
Large text (≥ 18 pt regular or ≥ 14 pt bold) 3:1 4.5:1 n/a
UI components and graphical objects n/a n/a 3:1

The "large text" carve-out exists because thicker strokes and larger sizes carry more visual weight, so the legibility floor relaxes. Many design systems get this wrong by treating all text uniformly at 4.5:1, which is conservative but wastes tokens — and worse, sometimes pushes teams toward darker colors that fight the brand.

A practical consequence: a 16 px button label is "large" only if it is bold, because 16 px regular sits below the 18 pt regular threshold and 14 pt bold. At default browser rendering, 18 pt is roughly 24 px and 14 pt is roughly 18.6 px. Bookmark those conversion values; they come up in every design-token review.

Five Edge Cases That Break Naive Implementations

Most contrast bugs are not calculation bugs; they are context bugs. The ratio is correct, but the surrounding assumption is wrong.

1. Translucent layers. A 70% white text on a dark surface is not the same as #FFFFFF at 70% on the same surface — and neither is the same as 70% white over the page background. The effective color depends on every compositing step below it. Audit tools that only read the declared color and background-color properties will miss this entirely. The fix is to flatten the stack: sample the pixel value at the text's position using a screenshot or a real browser, then compute the ratio against that.

2. Focus rings and outlines. A 2 px focus ring is a "graphical object" and needs 3:1 against whatever it sits on, including adjacent siblings. If a ring straddles a card edge and the page background, both halves must clear 3:1. This is why accessibility audits frequently flag custom focus indicators that look fine in isolation but fail on patterned or photographic backgrounds.

3. Disabled state. WCAG explicitly exempts disabled controls from contrast requirements, but many teams disable a control by reducing opacity on the same color tokens used for the enabled state. The result is text that passes in Figma, fails in the audit report, and confuses reviewers because the markup is unchanged. Decide upfront whether disabled UI lives inside or outside the contract.

4. Hover and active swaps. Flipping a button's background on hover can drop the ratio below 4.5:1 if the new background is darker but the text color stays the same. The static screenshot in the design file shows the resting state; the audit catches the hover state. Every interactive surface needs at least two ratio checks, not one.

5. Gradient backgrounds. There is no single ratio for text over a gradient. The defensible practice is to pick the gradient stop with the worst-case luminance against the text and verify that point. If that point fails, add a scrim or restrict the text to a band where every background value clears the threshold. The deeper walkthrough on the underlying math and a worked example is in the Lizely guide on checking color contrast for web accessibility.

A Token-Level Audit Routine That Scales

Manual spot-checks do not survive a codebase that grows. The routine below runs as a pre-merge check and catches the same class of bugs a human reviewer would, in seconds.

  1. Export the design tokens as JSON: every color, every semantic alias (--text-primary, --surface-elevated, --border-subtle), every state (resting, hover, active, disabled, focus).
  2. For each (foreground, background) pair declared in the component spec, compute the ratio using the WCAG formula. Store the result alongside the token name.
  3. Compare against the threshold table above. Flag pairs whose ratio falls below the level their usage requires. Sort by severity: body-text failures first, large-text second, non-text third.
  4. For each failure, record the suggested fix: lighten or darken the foreground or background by the minimum amount that clears the threshold. Luminance moves non-linearly with hex values, so prefer HSL adjustments that target the lightness channel.
  5. Run the same check against the live site using a headless browser. The static token check catches declared values; the runtime check catches compositing bugs (case 1 above) and CSS overrides that the design file does not model.

This routine takes longer to describe than to run. In a real codebase, step 2 is a 30-line script that walks the token file; steps 3 and 4 are a single pass; step 5 is a Playwright job that runs on every pull request.

How to Pick Between Two Failing Fixes

When a pair fails, you usually have three options: darken the foreground, lighten the background, or add a border or shadow to artificially increase perceived separation. The choice depends on the surrounding layout.

  • Darkening the foreground is the safest move on light surfaces. It preserves the surface color, which usually carries brand meaning.
  • Lightening the background is the safest move on dark surfaces. It preserves text rendering, which carries the brand voice.
  • Borders and shadows are a last resort. They help non-text contrast (icons, focus rings) but rarely rescue body text because the text edges still touch the background.

Avoid the temptation to fix the ratio by tweaking opacity. Opacity changes the effective color, which means the audit must sample a composited pixel, which means the next person reading the code cannot tell what the rendered color is. Keep color values fully opaque and let layering — explicit background tokens — handle the rest.

When a "Passing" Ratio Is Still a Bug

A token pair that clears 4.5:1 can still fail in production for three reasons that are easy to miss:

  • Color blindness. The ratio formula assumes normal trichromatic vision. Two colors that pass luminance contrast can still be indistinguishable under deuteranopia or protanopia. Run the same palette through a simulator before declaring victory.
  • Small text weight misclassification. A 16 px label declared as font-weight: 500 is treated as bold by some screen magnifiers and as regular by others. When the audit tool disagrees with the design intent, the conservative answer wins: assume the stricter threshold.
  • Forced colors mode. Users with Windows High Contrast or similar settings override your palette entirely. Your code must degrade gracefully — meaning the structural information (which element is a button, which is a link) survives when colors do not.

These three are the difference between a token set that passes a static linter and a token set that survives a real audit.

Frequently asked questions

What is the difference between AA and AAA, and which one should I target?

AA is the legal and contractual baseline in most jurisdictions and is what most product teams target. AAA at 7:1 is stricter and applies to body text in long-form reading contexts. Pick AA as the floor and AAA as the goal for primary reading surfaces like article body and form labels.

Why does my contrast look fine in the design tool but fail in the audit?

Design tools often display colors against a flat background, while the live page composites against gradients, images, or translucent layers. The audit is correct; the screenshot is the lie. Sample the actual pixel.

Do I need to check contrast for icons and SVGs?

Yes, if the icon conveys meaning (a close button, a status indicator). WCAG requires 3:1 for graphical objects against their adjacent colors. Decorative icons that carry no information are exempt, but you must mark them as decorative in the markup (aria-hidden="true" or equivalent) so assistive technology ignores them.

How often should the token audit run?

On every change to the design token file, and on every component that introduces a new color combination. A weekly scheduled run against production catches drift from CSS overrides and third-party widgets that the token file does not model.


This article was drafted with AI assistance and reviewed for technical accuracy before publishing.

Top comments (0)