Alpha Compositing - Why Transparent Text Contrast Is Tricky
Alpha compositing blends a semi-transparent colour over its background using the formula result = alpha × foreground + (1 − alpha) × background, per channel. A 50%-opaque black over white composites to grey, and must be done before measuring contrast.
Why it matters
A common contrast mistake is measuring rgba(0 0 0 / 0.6) as if it were solid black. It isn't. Over a white card it reads as mid grey with far less contrast, and over a dark card it may vanish entirely. The same faded text passes on one background and fails on another. Compositing first is the only honest way to check it, which is why the extension blends before it measures and tells you it did.
How it works
Compositing uses the "over" operator. When you place a semi-transparent foreground over a background, each channel blends per the formula:
result = alpha × foreground + (1 − alpha) × background
The alpha channel is a decimal from 0 to 1, where 1 is fully opaque and 0 is fully transparent. An rgba value of rgba(0 0 0 / 0.6) has alpha = 0.6. If the background is white (255, 255, 255), the blended result is:
- Red:
0.6 × 0 + 0.4 × 255 = 102 - Green:
0.6 × 0 + 0.4 × 255 = 102 - Blue:
0.6 × 0 + 0.4 × 255 = 102
So the faded black becomes an RGB grey at (102, 102, 102) – much brighter than the solid black you started with. That grey has far lower contrast against white than pure black does, and it may fail a 4.5:1 check even if black would pass.
The background is not a detail; it is part of the answer. Put the same rgba(0 0 0 / 0.6) over a dark grey background and the result is darker still. Contrast changes.
What does not matter
Premultiplied alpha is an optimisation for blending performance in graphics pipelines; for this purpose, straight alpha (what rgba uses) is the right model to use.
Code example
Correct – compositing before measuring contrast:
// Semi-transparent text over a background
const textColor = "rgba(0 0 0 / 0.6)"; // 60% opaque black
const bgColor = "rgb(255 255 255)"; // white
// Composite: each channel is (alpha × fg) + ((1 - alpha) × bg)
const alpha = 0.6;
const fg = [0, 0, 0]; // black
const bg = [255, 255, 255]; // white
const composite = [
Math.round(alpha * fg[0] + (1 - alpha) * bg[0]), // 102
Math.round(alpha * fg[1] + (1 - alpha) * bg[1]), // 102
Math.round(alpha * fg[2] + (1 - alpha) * bg[2]), // 102
];
// composite is now rgb(102, 102, 102) – grey, not black
// Measure contrast of this grey against white, not the original black
Wrong – ignoring the background:
// Measuring contrast of rgba(0 0 0 / 0.6) as if it were rgb(0 0 0)
// The text reads as grey when placed on white, not pure black
// Contrast fails if you assume it is black
const textColor = "rgba(0 0 0 / 0.6)";
const bgColor = "rgb(255 255 255)";
// WRONG: measuring the original rgba values directly
// The actual rendered colour is grey, not what these numbers suggest
How Scalpel Color shows it
When you paste a semi-transparent colour into the Contrast tab, Scalpel composites it over the background colour you choose, then measures the resulting contrast ratio. The panel notes that compositing happened, so you know the ratio reflects the actual rendered text, not an assumed opaque version.