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, 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.
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:
- 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 each stop, 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 (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.
Background images and translucent layers
Stops are enough for 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.
Where it stops: the honest limits
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.
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.
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?
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-07-26.