---
title: 'Transparency and Blending'
source: 'https://academia.sh/en/courses/css-fundamentals/transparency-and-blending'
course: 'Visual Presentation with CSS'
language: en
updated: '2026-08-17T18:09:19+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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:

$$C_{\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:

$$\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).

```js
// 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.

```css
/* 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.
