My portfolio uses an indigo → violet → cyan gradient on headline words and primary
buttons. It looks sharp. It also quietly failed accessibility, and no tool told me.
Why checkers miss it
WCAG AA wants 4.5:1 contrast between text and its background. Every contrast checker
takes one foreground and one background colour. But white text on a gradient
does not have one background — it has every colour along the ramp.
So I computed the ratio at each gradient stop instead of eyeballing it:
| Gradient stop | White text contrast | AA (4.5:1) |
|---|---|---|
Indigo #6366f1
|
4.6:1 | pass |
Violet #8b5cf6
|
3.9:1 | fail |
Cyan #22d3ee
|
1.81:1 | fail badly |
At the cyan end, white-on-cyan is nearly invisible to low-vision users — and this was
the call-to-action button. Worse, the hover state shifted the gradient toward cyan, so
the button got less readable exactly when someone showed intent to click it.
The fix: two gradients with different jobs
The display gradient is a brand asset; the button background is a legibility surface.
Those are different jobs, so they got different ramps:
:root {
/* Headline words only — never carries text on top of it. */
--gradient: linear-gradient(110deg, #6366f1, #8b5cf6 45%, #22d3ee);
/* Filled surfaces carrying white text. Every stop clears 4.5:1. */
--gradient-solid: linear-gradient(110deg, #4f46e5, #7c3aed);
}
The display gradient survives untouched where it is safe — clipped into text with
background-clip: text, where the text is the gradient rather than sitting on it. The
solid ramp goes everywhere white text sits on a fill. Visually the two read as the same
brand; mathematically only one of them ever carries text.
The habit worth keeping
If a design uses gradients under text, check the ratio at every stop, in every
state — including hover — with a script, not a screenshot. It is ten lines of
relative-luminance maths, and it catches the failure the single-pair checkers are
structurally unable to see.
Originally published on my portfolio.
Top comments (1)
The hover detail is the part I would put at the top. The state nobody screenshots moved toward the failing end, so the button was least readable at the exact moment someone decided to use it. That direction is not a coincidence either. Hover states tend to get brighter, and brighter is where white text goes to die.
On the fix, one thought about whether it survives you. Splitting the value by job is the right move. But
--gradient-solidnames what it looks like rather than what it is for, so somebody six months from now reads the two names as a visual variant and picks either one.--gradient-displayand--gradient-text-surfaceput the rule inside the name, and then the wrong choice stops being available.That seems like the general shape here. The moment one value serves two jobs with different constraints, one name cannot hold it. You only found out because one of the two jobs happened to have a check attached.