Engineering
Resolving color contrast over CSS gradients
By Chris Morris · updated 2026-09-15
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 the three 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. For light text the shortcut 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 passes. About 37% of the way across, the blend is a muddy brown and the ratio there is 3.49:1, which fails. The same curve means the lightest point of a blend is always at a stop, which is why white text was never affected.
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. Our scanner originally measured only the stops, and so could miss exactly this case. It now samples between them (engine revision 5, September 2026). The contrast checker shows where along a gradient the lowest point falls.
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:
- Read the computed
color(the text) and the font size and weight, which set the required ratio (4.5 or 3). - Walk up the ancestors to find the nearest element that actually paints a background: a
gradient(, aurl()image, or a colour. - Pull the colour stops out of that value. This part is fussier than it looks. It is tempting to match
rgb()andrgba()with a regular expression, but current CSS computes colours tocolor(srgb ...),oklch(),lab()andcolor-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. - Compute the contrast between the text colour and the background at each stop and at closely spaced points between stops, blending the way the browser does, and keep the minimum.
- 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++) {
for (let s = 0; s <= 48; s++) {
// blend in the colour space the browser uses for this gradient
const bg = mix(stops[i], stops[i + 1], s / 48);
worst = Math.min(worst, contrast(textColor, bg));
}
}
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.
Background images and translucent layers
Sampling between the stops covers an opaque gradient, but two very common cases need more than CSS maths, 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 it still leaves for review
Plenty is still left as "needs review", 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. Images on a CDN without the right header therefore stay unresolved.
- Gradients on a separate overlay element. We look up the ancestors of the text. A gradient painted by a sibling element that merely sits on top, which is a common hero pattern, is not on that path, so it is not measured.
- Pseudo-element backgrounds, and text partly covered by another element.
- Fixed-attachment images, where the image is anchored to the viewport rather than the element, so the element box no longer says which part of it is behind the text. A fixed gradient is still fine, because its stops do not move.
- Opacity, filters and blend modes on an ancestor, and text shadows.
- Gradients blended in a hue-based colour space, such as
in hslorin oklch. The path the colour takes around the hue wheel is harder to reproduce exactly, so these stay with a person.
The rule is the same as before: resolve what can be established, and flag the rest for a human. Claiming more than the measurement 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. To test one gradient or image by hand, use the image and gradient contrast checker.
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.
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, which is what our scanner and contrast checker do.
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. Two limits are worth knowing: an image served from another domain cannot be sampled unless that server permits it, and a gradient painted by a separate overlay element rather than by an ancestor of the text is not picked up. Both stay as "needs review" rather than being guessed.
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 scanLast updated 2026-09-15.