Skip to content
Accessibility Scanner

Engineering

Resolving color contrast over CSS gradients

Run an automated accessibility check on a modern landing page and the headline often comes back as "needs review" rather than pass or fail. The usual reason is a gradient. Here is why that happens, and the small piece of maths that resolves most of it.

See it on your own page, free scan.

Why gradients become a blind spot

The contrast check in axe-core compares the colour of text against the colour behind it. To do that reliably it reads the computed background-color of the element and its ancestors, a single solid value it can reason about.

A CSS gradient is not a solid colour. It is set through background-image: linear-gradient(...), so when text sits directly on one, axe has no single background value to measure against. Rather than guess, it does the responsible thing and returns the result as incomplete, the "needs review" state. That is correct, but it has an awkward side effect: the text it punts on is frequently the most prominent text on the page, the hero headline sitting on a colourful gradient banner.

The insight: the worst case lives at a stop

You do not actually need to sample every pixel under the text to know whether it passes. A gradient is fully defined by its colour stops. Between any two adjacent stops the colour moves smoothly from one to the other, and contrast against a fixed text colour changes smoothly with it. There is no hidden spike in the middle.

That means the lowest contrast, the worst case, has to occur at one of the stops. So the whole question reduces to: compute the contrast between the text colour and each stop, take the smallest, and compare it to the threshold. If the worst stop passes, the text passes across the entire gradient. If the worst stop fails, you have a real failure with a real number.

The WCAG contrast maths

The thresholds come from WCAG 1.4.3 Contrast (Minimum): a ratio of at least 4.5:1 for normal text, or 3:1 for large text (24px and up, or 18.66px and up when bold). The ratio itself is built from relative luminance.

Each channel is linearised, then weighted:

// 0–255 channel → linear light
const lin = (c) => { c /= 255; return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); };
// relative luminance
const lum = (c) => 0.2126*lin(c.r) + 0.7152*lin(c.g) + 0.0722*lin(c.b);
// contrast ratio between two colours
const contrast = (a, b) => {
  const hi = Math.max(lum(a), lum(b)), lo = Math.min(lum(a), lum(b));
  return (hi + 0.05) / (lo + 0.05);
};

This is the same formula axe uses for solid backgrounds. We are not changing how contrast is judged, only giving it the right colours to judge.

The algorithm, step by step

For each element axe left as "needs review" for colour contrast:

  1. Read the computed color (the text) and the font size and weight, which set the required ratio (4.5 or 3).
  2. Walk up the ancestors to find the nearest element whose computed background-image actually contains gradient(.
  3. Pull the colour stops out of that value. Browsers compute keywords and hex to rgb() / rgba(), so the stops are already concrete numbers.
  4. Compute the contrast between the text colour and each stop, and keep the minimum.
  5. If that worst-case ratio is below the requirement, it is a genuine failure. If it clears the bar, the text passes across the whole gradient, so the "needs review" can be dropped entirely.
let worst = Infinity;
for (const stop of stops) worst = Math.min(worst, contrast(textColor, stop));
if (worst < required) {
  // real failure, e.g. "lowest-contrast point is 1.37:1, below the required 3:1"
}

The result is that a headline which used to come back as an unknown now comes back as "fails at 1.37:1", with the exact element, which is something a developer can actually act on.

Where it stops: the honest limits

This resolves opaque CSS gradients, and deliberately nothing more. Two cases are left as "needs review" on purpose:

  • Background images. If the background is a url() photo, the effective colour under the text cannot be derived from CSS. That genuinely needs pixel sampling, which is a separate problem.
  • Semi-transparent stops. If any stop has an alpha below 1, the real colour depends on whatever sits behind it, so the stops alone are not enough.

It also does not try to account for text shadows, blend modes, or text that overlaps a gradient edge. The rule we hold to is simple: resolve what can be proven from the CSS, and honestly flag the rest for a human. Claiming more than the maths supports would just be a prettier version of the "needs review" problem.

Why it matters

Gradients are everywhere in modern design, and they tend to sit behind the text that matters most. Leaving all of it as "needs review" pushes the single most visible contrast decision onto a manual pass that often never happens. Resolving the opaque-gradient case turns a common blind spot into a concrete pass or fail, and it is the kind of detail that separates a tool that runs axe from one that does something useful with the output. You can see it on your own pages with the free scan, or wire it into CI with the CLI and GitHub Action.

Frequently asked questions

Why does axe-core mark gradient text as "needs review"?

Because its contrast check reads a solid background-color, and a gradient is set via background-image. With no single background value, axe correctly returns the result as incomplete rather than guessing.

Why is checking the gradient stops enough?

Contrast against a fixed text colour changes smoothly between stops, so the lowest contrast must occur at a stop. Checking each stop and taking the worst case covers the entire gradient without sampling pixels.

Does this work for background images?

No. The effective colour under text on a photo cannot be derived from CSS, so image backgrounds (and semi-transparent gradients) are left as "needs review". Resolving those needs pixel sampling.

What contrast ratios are required?

WCAG 1.4.3 requires 4.5:1 for normal text and 3:1 for large text (24px and up, or 18.66px and up if bold).

Related guides

See what's actually broken on your site

Real axe-core results, every element outlined. No email wall, no fake “compliant” badge.

Run a free scan

Last updated 2026-06-26.