Lesson 18 / 26
Transparency and Blending
The alpha channel's blending calculation, the difference between opacity and alpha, and evaluating a blended color's contrast ratio against WCAG criteria.
Contents
The previous lesson treated colors as fully opaque, and the alpha channel seen in the eight-digit hexadecimal writing went by unexplained. What color results when a transparent color overlaps a base?
The answer to this question is a calculation, and the calculation’s result directly affects legibility. A text color made transparent mixes with the base and turns into a paler color than expected, and the contrast ratio drops. This lesson takes up the blending calculation and the contrast ratio, numerically.
Alpha Blending
The alpha channel declares how opaque a color is: 1 is fully opaque, 0 is fully transparent. When a transparent color overlaps a base, a weighted average gets taken for each component:
The contrast ratio, though, is defined in WCAG. First each color’s relative luminance gets calculated — the channels get linearized and summed with weights according to eye sensitivity — then the two luminances get put into this relation:
The result is a number between 1:1 (the same color) and 21:1 (black–white).
// blend.mjs — alpha blending and the WCAG contrast ratio const hexToRgb = (hex) => { let h = hex.replace("#", ""); if (h.length === 3) h = [...h].map((c) => c + c).join(""); return { r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16) }; }; // the top color gets blended onto the base by alpha: C = a*Ctop + (1-a)*Cbase function blend(top, alpha, base) { const t = hexToRgb(top), b = hexToRgb(base); const mix = (x, y) => Math.round(alpha * x + (1 - alpha) * y); return { r: mix(t.r, b.r), g: mix(t.g, b.g), b: mix(t.b, b.b) }; } const toHex = ({ r, g, b }) => "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join(""); // WCAG 2 relative luminance function relativeLuminance({ r, g, b }) { const channel = (v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } // contrast ratio: (L1 + 0.05) / (L2 + 0.05), L1 >= L2 function contrast(a, b) { const la = relativeLuminance(a), lb = relativeLuminance(b); const [hi, lo] = la > lb ? [la, lb] : [lb, la]; return (hi + 0.05) / (lo + 0.05); } console.log("--- alpha blending (base #ffffff) ---"); for (const alpha of [1, 0.8, 0.5, 0.2, 0]) { const s = blend("#143a52", alpha, "#ffffff"); console.log(`alpha=${alpha.toFixed(2)} -> rgb(${s.r} ${s.g} ${s.b}) = ${toHex(s)}`); } console.log("\n--- same color on a dark base (#1c2733) ---"); for (const alpha of [1, 0.8, 0.5, 0.2]) { const s = blend("#143a52", alpha, "#1c2733"); console.log(`alpha=${alpha.toFixed(2)} -> ${toHex(s)}`); } console.log("\n--- contrast ratios ---"); const pairs = [ ["#1c2733", "#ffffff"], ["#1c2733", "#f5f7f8"], ["#143a52", "#ffffff"], ["#8a1c1c", "#ffffff"], ["#8a5b00", "#ffffff"], ["#6b767f", "#ffffff"], ["#d5dbe0", "#ffffff"], ["#ffffff", "#143a52"], ]; for (const [fg, bg] of pairs) { const o = contrast(hexToRgb(fg), hexToRgb(bg)); const normal = o >= 7 ? "AAA" : o >= 4.5 ? "AA" : "fails"; const large = o >= 4.5 ? "AAA" : o >= 3 ? "AA" : "fails"; console.log(`${fg} / ${bg} -> ${o.toFixed(2)}:1 normal text ${normal.padEnd(6)} large text ${large}`); } console.log("\n--- contrast of transparent text ---"); for (const alpha of [1, 0.7, 0.5, 0.35]) { const s = blend("#1c2733", alpha, "#ffffff"); const o = contrast(s, hexToRgb("#ffffff")); console.log(`alpha=${alpha.toFixed(2)} -> ${toHex(s)} contrast ${o.toFixed(2)}:1 ${o >= 4.5 ? "AA passes" : "AA fails"}`); }
--- alpha blending (base #ffffff) --- alpha=1.00 -> rgb(20 58 82) = #143a52 alpha=0.80 -> rgb(67 97 117) = #436175 alpha=0.50 -> rgb(138 157 169) = #8a9da9 alpha=0.20 -> rgb(208 216 220) = #d0d8dc alpha=0.00 -> rgb(255 255 255) = #ffffff --- same color on a dark base (#1c2733) --- alpha=1.00 -> #143a52 alpha=0.80 -> #16364c alpha=0.50 -> #183143 alpha=0.20 -> #1a2b39 --- contrast ratios --- #1c2733 / #ffffff -> 15.14:1 normal text AAA large text AAA #1c2733 / #f5f7f8 -> 14.09:1 normal text AAA large text AAA #143a52 / #ffffff -> 11.95:1 normal text AAA large text AAA #8a1c1c / #ffffff -> 9.28:1 normal text AAA large text AAA #8a5b00 / #ffffff -> 5.87:1 normal text AA large text AAA #6b767f / #ffffff -> 4.64:1 normal text AA large text AAA #d5dbe0 / #ffffff -> 1.40:1 normal text fails large text fails #ffffff / #143a52 -> 11.95:1 normal text AAA large text AAA --- contrast of transparent text --- alpha=1.00 -> #1c2733 contrast 15.14:1 AA passes alpha=0.70 -> #606870 contrast 5.66:1 AA passes alpha=0.50 -> #8e9399 contrast 3.10:1 AA fails alpha=0.35 -> #b0b3b8 contrast 2.10:1 AA fails
Blending Depends on the Base
The output’s first two blocks blend the same color onto two different
bases. The color #143a52 turned into #8a9da9 on a white base at
0.5 alpha, and into #183143 on a dark base. The two results do not
resemble each other.
The consequence is this: a transparent color is not a fixed color. Once a component gets a transparent background, the component’s appearance depends on where it gets placed. This is sometimes the desired behavior — a shadow or an overlay layer should blend with its base. Sometimes it is not: a warning badge’s color should not change depending on where it sits.
The output’s last line shows a boundary case: when alpha is zero, the
result is entirely the base’s color. The transparent keyword
expresses exactly this.
opacity Differs From Alpha
The two mechanisms look similar but do different things.
Color alpha only affects the property that color gets applied to.
Writing background-color: rgb(20 58 82 / 0.5) only makes the
background transparent; the text and border stay opaque.
opacity, though, makes the element’s entirety — background,
text, border, and all its children — transparent as a single layer.
It does not get applied to child elements separately; the element
gets drawn within itself first, then gets blended as a whole.
This has two consequences. First, the transparency of text inside a
box given opacity: 0.5 cannot get canceled; writing opacity: 1 on
the child does nothing. Second, as noted in the Stacking Context
lesson, every opacity value below 1 establishes a stacking context —
because blending as a whole requires exactly that.
The applicable rule: if a color needs to be made transparent, color
alpha gets written; if an element’s entirety needs fading, opacity
gets written.
Reading the Contrast Ratio
The output’s third block gives the ratios evaluated against the WCAG criteria. The criteria are these:
| Criterion | Normal text | Large text |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
Large text is text at or above 24 pixels non-bold, or 18.66 pixels bold. For visual components other than text — form field borders, icons, focus indicators — the criterion is 3:1.
The rows in the table make three kinds of decision.
The color #6b767f gives 4.64:1 on white and passes the AA criterion
by a hair. Pale text colors hover around this boundary; a
one-step change can drop below the criterion.
The color #d5dbe0 passes no criterion, at 1.40:1. This color was
chosen as the line color and cannot get used as a text color. It
stays below the 3:1 criterion even for a border; it can count as
decorative as a divider line, but is insufficient for showing a form
field’s boundary.
The last row shows symmetry: #ffffff / #143a52 and #143a52 / #ffffff give the same ratio. The contrast ratio is directionless; it
does not care which one is the foreground and which is the
background.
Transparency Lowers Contrast
The output’s last block is this lesson’s most practical consequence.
Fully opaque #1c2733 text gives 15.14:1 on a white base. Once the
same color gets written with 0.5 alpha, the ratio drops to 3.10:1 and
does not pass the AA criterion.
Using alpha to “fade” a text is therefore dangerous: what actually happens is less a visual adjustment than a loss of legibility, and the loss stays invisible until it gets calculated. The right approach is to pick the pale tone from the scale and calculate the chosen tone’s contrast ratio.
This calculation in WCAG 2 has a known limitation: relative luminance does not reflect perceived contrast with the same accuracy for every color pair — it drifts especially on dark bases and saturated colors. The criterion still provides a measurable, testable floor; the calculation reads as a lower bound that has to get passed, not as the definition of good design.
/* station.css — step 17: transparency and contrast */ .source-note { color: hsl(211 12% 45%); } /* opaque tone, not alpha */ .visual-texture { background-color: rgb(20 58 82 / 0.08); } .overlay { position: fixed; inset: 0; background-color: rgb(28 39 51 / 0.6); } .disabled { opacity: 0.55; pointer-events: none; }
.source-note uses an opaque tone chosen from the scale for its pale
text, instead of alpha. .visual-texture and .overlay, though, use
alpha in the right place: both are layers meant to blend with
their base.
The .disabled class uses opacity because the element’s entirety —
text, border, background — needs to fade together. pointer-events: none keeps the visual state consistent with interaction; but the
disabled information has to get declared in the document with
disabled or aria-disabled. Transparency alone tells no assistive
technology anything.
Summary
- Alpha blending is a weighted average: the result color gets found by multiplying the top color by alpha and the base color by the remaining share, then summing them.
- A transparent color is not a fixed color; its result depends on the base, and the same declaration produces two different colors on two different bases.
- Color alpha only affects that declaration;
opacitymakes the element and all its children transparent as a single layer and establishes a stacking context. - The contrast ratio gets calculated from relative luminances, falls between 1:1 and 21:1, and is directionless; the AA criterion is 4.5:1 for normal text, 3:1 for large text.
- Fading a text with alpha lowers the contrast ratio; the pale tone gets chosen from the scale as a calculated opaque color.
Next Step
The color decisions are made, but the text itself has not been touched yet: which font, what size, what line spacing it gets written with have not been determined. The next lesson takes up typography and shows, with calculation, how a font family gets chosen, how a size scale gets built, and why line height gets written unitless.
To keep your progress and take notes, Log in
My notes
Log in to take notes.