DEV Community

Chris Morris
Chris Morris

Posted on Edited on Originally published at accessibilityscanner.app

Resolving color contrast over CSS gradients

Correction, September 2026. The central claim in the original version of this post was wrong, and I have rewritten it. I said the worst-case contrast across a gradient always sits at one of its colour stops. That holds for light text and fails for dark text, because a blend of two colours can be darker than either one. My own scanner measured gradients this way and reported passes it should not have. The corrected method is below, along with the counterexample that found it.

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, a background photo, or a scrim over one. Here is why that happens, and how each of them gets resolved into an actual pass or fail.

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.

Why checking the stops is not enough

A gradient is fully defined by its colour stops, so the obvious shortcut is to measure the text against each stop and take the worst result. That is what I originally built, and what this post originally recommended. For light text it holds. For dark text it can pass a gradient that fails.

The reason is how browsers blend. Between two stops, a gradient written with ordinary colours mixes the red, green and blue values as they are stored, not as amounts of light. Luminance, which the WCAG ratio is built on, curves against those stored values, so a mix of two colours can come out darker than either of them.

Take black text on linear-gradient(90deg, #ff0000, #00c800). Against the red stop it measures 5.25:1, and against the green stop 9.26:1. Both pass. About 37% of the way across, the blend is a muddy brown and the ratio there is 3.49:1, which fails. Checking only the stops reports a pass on text that is genuinely too dark to read against part of its own background.

The same curve explains why light text was never affected. Luminance along one of these segments is convex, so its maximum is always at a stop. The minimum is the one that can hide in the middle.

So the check has to sample along each stretch between stops, blending in the same colour space the browser uses, and keep the lowest value it finds.

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);
};
Enter fullscreen mode Exit fullscreen mode

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 that actually paints a background: a gradient(, a url() image, or a colour.
  3. Pull the colour stops out of that value. This part is fussier than it looks. It is tempting to match rgb() and rgba() with a regular expression, but current CSS computes colours to color(srgb ...), oklch(), lab() and color-mix(), and Tailwind v4 emits those by default. A regex quietly finds nothing on a modern site and the whole check falls back to "needs review". So each colour token is painted to a one-pixel canvas and read back instead, which works for any syntax the browser itself understands.
  4. Sample each segment between adjacent stops, mixing in the space the browser uses for that gradient, and keep the minimum contrast across every stop and sample.
  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 (let i = 0; i < stops.length - 1; i++) {
  worst = Math.min(worst, contrast(textColor, stops[i]));
  for (let s = 1; s < SAMPLES; s++) {
    // mix in the colour space the browser blends this gradient in
    const bg = mix(stops[i], stops[i + 1], s / SAMPLES);
    worst = Math.min(worst, contrast(textColor, bg));
  }
}
worst = Math.min(worst, contrast(textColor, stops[stops.length - 1]));
if (worst < required) {
  // real failure, e.g. "lowest-contrast point is 3.49:1, below the required 4.5:1"
}
Enter fullscreen mode Exit fullscreen mode

Which space to mix in matters. A gradient can name one (in srgb, in oklab), and otherwise the default depends on the syntax of its stops: sRGB when they are all written in legacy notation, Oklab when any of them uses CSS Color 4 notation. A hard edge, where two stops sit at the same position, paints nothing between them, so nothing is sampled there.

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

Background images and translucent layers

Stops alone were never going to cover two very common cases, and both are now resolved as well.

Background images. The colour under text on a photo cannot be derived from the stylesheet, so the image is decoded onto a canvas and the pixels underneath the text are sampled directly. The element box, background-size, background-position and background-repeat together say which part of the image is actually behind the text, and the worst contrast across that region decides. A headline that is readable over the dark half of a photo and invisible over the light half is a real failure, and averaging would hide exactly that.

Translucent layers. A dark scrim over a hero photo is the standard way to make white text work. If a stop or an overlay has an alpha below 1, it gets composited over whatever sits beneath it, the same way the browser paints it, rather than being abandoned as unknowable.

What still stays "needs review"

Plenty is still left for a human, on purpose. A wrong "needs review" costs somebody a few minutes. A wrong "pass" tells them their site is fine when it is not, which is the whole problem with compliance badges. So anything that cannot be established is left alone:

  • Images from another domain. Reading pixels back from a canvas that has loaded a cross-origin image is blocked by the browser unless that server allows it.
  • Gradients blended in a hue-based space, such as in hsl or in oklch. The path the colour takes around the hue wheel is harder to reproduce exactly.
  • Any stop that will not parse. Dropping one silently could discard the very colour the text fails against, which is its own way of inventing a pass.
  • Gradients painted by a separate overlay element that merely sits on top of the text rather than being one of its ancestors, which is a common hero pattern.
  • Pseudo-element backgrounds, text partly covered by another element, fixed-attachment images, ancestor opacity, filters and blend modes, and text shadows.

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 it turns a common blind spot into a concrete pass or fail.

It also turned out to be a good lesson in checking a shortcut before trusting it. The stop-only version was fast, easy to reason about, and wrong in exactly the direction that matters, because a false pass is the thing an accessibility tool must never produce. You can try a gradient or a photo yourself in the contrast checker, which runs entirely in your browser, or scan a page with the free scan or the CLI and GitHub Action.

FAQ

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.

Is checking the gradient stops enough?

For light text, yes: the lightest point of a blend is always at a stop. For dark text, no. Browsers blend the stored red, green and blue values, and the result can be darker than both stops, so dark text can pass at every stop and fail in between. Sample along each stretch between stops instead.

Does this work for background images?

Yes. The colour under text on a photo cannot be read from CSS, so the image is decoded onto a canvas and the pixels behind the text are sampled, with the worst contrast across that area deciding the result. Translucent overlays such as a scrim on a hero image are composited the way the browser paints them. An image served from another domain still cannot be sampled unless that server permits it.

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).


I build a few products. This one is Accessibility Scanner, a free WCAG scanner with no overlay and no fake compliance badge.

Top comments (1)

Collapse
 
kaifi_azam_21 profile image
Kaifi Azam

Gradients are where the usual contrast check gets surprisingly messy because there isn’t really one background color to compare against. For solid pairs I’ve found it useful to check candidate colors in Olivez Color Picker while building the palette, then treat anything sitting over a gradient as a separate worst-case check rather than assuming the palette itself is accessible.