Skip to content

Contrast: don't blend translucent backgrounds against white when an ancestor has a gradient/background image - #172

Merged
adamchaboryk merged 5 commits into
ryersondmp:dev-5.1.0from
skerbis:fix/contrast-translucent-backgrounds
Sep 24, 2026
Merged

adamchaboryk merged 5 commits into
ryersondmp:dev-5.1.0from
skerbis:fix/contrast-translucent-backgrounds

Conversation

@skerbis

@skerbis skerbis commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Hi Adam,

first of all: thanks for Sa11y, it's a great tool. I ship it to REDAXO CMS users via the for_sa11y addon, so editors get the checks right on their pages while working.

The problem

While going through a customer site I kept getting contrast errors with a ratio of about 1.1:1 on text that clearly looked fine: white text inside a "pill" with a translucent background like rgba(75, 187, 217, .14), sitting on a section with a dark linear-gradient background.

Turns out getBackground() only walks up looking for an ancestor background-color when the element's own background has alpha. Ancestors with a background-image (gradients included) are skipped, so it falls back to white and blends the translucent colour against that. Hence white on white → 1.1:1 → error. The same page with axe-core was clean, since axe composites the layers, so I thought this should be fixable in Sa11y too.

A second thing I noticed on the way: if there are two translucent layers on top of each other (e.g. a card with rgba(255,255,255,.3) inside a wrapper with rgba(255,255,255,.3) on a black section), the first translucent ancestor is treated as opaque. That hides real failures.

The fix

  • If an ancestor has a background-image / gradient, getBackground() now returns an image background and carries the translucent colour as overlay. checkContrast() blends that overlay onto each gradient stop, so the element is handled like any other gradient background: no issue if all stops pass, a "needs manual review" warning if one fails, and no false error against white anymore.
  • Multiple translucent layers are composited with the normal "over" operator (stackTranslucent()) before blending with the first opaque ancestor.
  • No behaviour change for solid backgrounds. Three new fixtures in test/pages/unit-tests.html (nothing-contrast-7, warning-contrast-2, error-contrast-7) and the ids added to the spec.

Test results

Compared the released 5.0.9 with this branch on the same real-world page, each in a fresh browser context:

Case 5.0.9 this PR
White text, translucent pill over a dark gradient error 1.1:1 (false positive) nothing
Same pill over a light gradient error (treated as solid) warning "needs manual review"
Same pill over a solid dark colour passes passes
Two rgba(255,255,255,.3) layers over black, white text nothing (false negative) error 3.8:1
  • The rest of the page produced exactly the same warnings/errors before and after, so no regressions there.
  • npx playwright test test/unit-tests.spec.js --project=chromium: 169 passed.
  • Biome is happy.

The second commit is just the rebuilt dist/ (feel free to drop it if you prefer building on release).

Thanks again for the great work on Sa11y!

Thomas Skerbis and others added 4 commits September 16, 2026 19:42
getBackground() blended a translucent background colour against white
whenever the ancestor chain had no opaque background-color, even when an
ancestor carried a background-image or gradient. White text on a
rgba(...) pill over a dark gradient section was therefore reported as a
CONTRAST_ERROR with a ~1:1 ratio, although the rendered contrast is fine.

- Ancestor with background-image/gradient: return an image background
  with the translucent colour as `overlay`; checkContrast blends the
  overlay onto each gradient stop, so the element is reviewed like any
  other gradient background (warning only if a stop fails, never a
  false error against white).
- Several translucent layers on top of each other are now composited
  ("over" operator) before blending with the first opaque ancestor.
  Previously the first translucent ancestor was treated as opaque,
  which hid real failures (e.g. two rgba(255,255,255,.3) layers over
  black with white text now correctly fail).
- Unit test fixtures: nothing-contrast-7, warning-contrast-2,
  error-contrast-7.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Gradient stops with alpha (e.g. a decorative glow such as
`radial-gradient(rgba(75,187,217,.16), rgba(75,187,217,0)), #313f4e`)
were checked as if they were opaque, so white text on a dark section
with a subtle glow was flagged for manual review, and even a fully
transparent stop counted as a solid colour.

getBackground() now records the opaque colour behind an image/gradient
(`base`: the element's own background-color, or the first opaque
ancestor after compositing translucent layers). checkContrast() blends
each stop onto `base` before the check; a descendant's translucent
background colour (`overlay`) is composited on top. When the colour
behind the gradient is unknown (nested images), behaviour is unchanged.

Fixtures: nothing-contrast-8, warning-contrast-3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@skerbis

skerbis commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit (04a256d) after testing this on more pages.

Second issue, same corner: gradient stops with alpha are checked as if they were opaque. A decorative glow like background: radial-gradient(rgba(75,187,217,.16), rgba(75,187,217,0)), #313f4e with white text produced "contrast unknown, needs manual review" on every heading in that section, because the stops were compared as solid #4bbbd9. Even the fully transparent stop counted as a solid colour.

What the commit does: getBackground() now records the opaque colour behind an image/gradient (base: the element's own background-color, or the first opaque ancestor after compositing translucent layers). checkContrast() blends each stop onto base before the check, and a descendant's translucent background colour (overlay, from the first commit) is composited on top. If the colour behind the gradient is unknown (nested images), nothing changes compared to today.

Results on the same page as above:

Case before after
White text, radial-gradient(rgba(cyan,.16), rgba(cyan,0)), #313f4e warning "needs manual review" nothing
White text, linear-gradient(rgba(0,0,0,.1), rgba(0,0,0,0)), #fff warning warning (correct)
White text, translucent gradient on an element without its own colour over white body warning warning (correct)
Translucent pill on top of the glow over dark error 1.1:1 nothing

169 unit tests still green, two more fixtures added (nothing-contrast-8, warning-contrast-3).

@adamchaboryk
adamchaboryk changed the base branch from master to dev-5.1.0 September 24, 2026 19:19
@adamchaboryk
adamchaboryk merged commit 320e1fe into ryersondmp:dev-5.1.0 Sep 24, 2026
@adamchaboryk

Copy link
Copy Markdown
Collaborator

Thank you very much for the contribution, @skerbis! What a great fix!

I'll include in next release!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants