Your contrast checker probably lies about rgba() text. Most checkers — and most hand-rolled WCAG snippets — assume both colours are fully opaque. The moment your text or its background uses rgba() or opacity, the numbers can be off by a full pass/fail. Here's why, and the one extra step that fixes it.
The WCAG math, quickly
The contrast ratio between two colours is:
(L1 + 0.05) / (L2 + 0.05)
where L1 is the relative luminance of the lighter colour and L2 the darker. Relative luminance needs sRGB gamma correction first — you can't just average the RGB channels:
function luminance([r, g, b]) {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
}
function ratio(fg, bg) {
const [l1, l2] = [luminance(fg), luminance(bg)];
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
}
Sanity check: black on white is 21:1, white on white is 1:1. If those two anchors are right, the core is right. AA wants 4.5:1 for normal text and 3:1 for large text (≥ 18pt, or 14pt bold).
So far so standard. Here's the part that bites.
The bug: luminance has no concept of alpha
luminance() takes three numbers. There is no fourth slot for alpha — and there can't be, because a translucent colour has no luminance of its own. What you actually see is the translucent colour blended over whatever is behind it.
Consider muted helper text that a designer wrote as:
color: rgba(0, 0, 0, 0.55); /* "black at 55%" */
background: #ffffff;
Run the naive check and you'll feed it [0,0,0] vs [255,255,255] and get a triumphant 21:1. But the pixels on screen are not black. They're black composited over white at 55% opacity, which is a medium grey — and its real ratio is closer to 3.7:1. That fails AA for body text. The checker said you passed by 4x. You shipped unreadable text.
The fix: composite first, then measure
Before computing luminance, flatten any translucent colour onto its actual backdrop using the standard "source-over" formula, per channel:
out = fg * alpha + bg * (1 - alpha)
// Flatten a translucent fg over an opaque bg.
function composite([fr, fg_, fb, fa], [br, bg_, bb]) {
return [
Math.round(fr * fa + br * (1 - fa)),
Math.round(fg_ * fa + bg_ * (1 - fa)),
Math.round(fb * fa + bb * (1 - fa)),
];
}
function ratioWithAlpha(fgRgba, bgRgb) {
const flat = composite(fgRgba, bgRgb); // real on-screen colour
return ratio(flat, bgRgb);
}
ratioWithAlpha([0, 0, 0, 0.55], [255, 255, 255]); // ~3.7 ✅ now honest
Now the result matches what a human eye (and an auditor's tooling) actually sees.
Three things that trip people up
1. opacity is not rgba(), and it's worse. opacity: 0.55 on an element fades the text and lets the background show through, but it also composites over every ancestor layer, not just the immediate parent. If there's a background image or a gradient behind the element, there is no single backdrop colour to composite against — the contrast genuinely varies pixel by pixel. The honest move is to avoid opacity for text colour entirely and use a solid colour you can actually measure.
2. A translucent background needs the same treatment. A card with background: rgba(255,255,255,0.8) over a photo isn't white — flatten it over the dominant backdrop colour before checking the text on it.
3. Stacked alphas compound. Translucent text on a translucent panel on a page background means two composites, innermost backdrop first. Resolve from the bottom layer up.
Takeaways
- The WCAG luminance formula has no alpha input by design — translucent colours must be flattened first.
- Always composite (
fg*a + bg*(1-a)) against the real backdrop before computing the ratio, or your checker reports a colour that never reaches the screen. - Prefer solid
rgb/hex for text. If you must fade it, pre-compute the flattened colour so design, code, and audit all agree on one value.
I kept re-deriving the compositing step every time a design used faded text, so I keep a small free color contrast checker around that takes alpha into account. Get the two anchor values (21:1 and 1:1) right, remember to flatten, and contrast stops being guesswork.












