scalpel@labs: ~/glossary/alpha-compositing.mdx5 sections

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.

extension: Scalpel Colorupdated: 2026-08-14read_time: 2 min
less alpha-compositing.mdx

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.

Sources