Skip to content
academia.sh

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:

Cresult=αCtop+(1α)CbaseC_{\text{result}} = \alpha \cdot C_{\text{top}} + (1 - \alpha) \cdot C_{\text{base}}

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:

ratio=Llight+0.05Ldark+0.05\text{ratio} = \frac{L_{\text{light}} + 0.05}{L_{\text{dark}} + 0.05}

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; opacity makes 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.

Start typing to search.

↑↓ Esc navigate · open · close