Module 4: Scales Color And Contrast
Computing contrastRatio
Overview
Lesson 5 gave you the thresholds; this one gives you the machine that produces the number compared against them. You're going to implement contrastRatio(fg, bg) with WCAG's real algorithm —the same one accessibility tools use and the law requires— in four steps: parse the hex into RGB channels, linearize each channel (undo sRGB's gamma correction), calculate relative luminance (where green weighs seven times more than blue, because that's how the eye sees), and get the ratio with the formula (lighter + 0.05) / (darker + 0.05). And you're going to verify it against known values: #000000 over #ffffff must give exactly 21:1, #ffffff over itself 1:1. By the end, contrast stops being something a website calculates for you: you calculate it, and you know why every step is there.
Connection with the module. It's the module's technical heart. It implements the number lesson 5 defined and lesson 7 will use to audit real pairs. It picks up lesson 4's lightness axis and makes it honest: there we used the channel average as a proxy (which lied about perceived lightness); here we replace it with relative luminance, the real measure. In the project (L8), this contrastRatio is what audits product-card.
An analogy: why the eye doesn't count light "raw"
Imagine you want to know how bright a screen looks. You could measure the physical energy it emits —the photons coming out— with a sensor. But that wouldn't tell you how a person perceives it, for two reasons the WCAG algorithm corrects, one in each of its two central steps.
The first: the eye isn't equally sensitive to every color. It's far more sensitive to green than to blue —a green and a blue emitting the same physical energy, the green looks much brighter. That's why luminance weighs: 0.2126·R + 0.7152·G + 0.0722·B. Green contributes 71% of perceived brightness, red 21%, blue barely 7%. It's like weighing ingredients in a recipe where each counts differently: you don't add equal grams, you weigh by their effect.
The second, subtler: the color values we write (the ff in #ffffff, or the 128 of a medium gray) are not proportional to the light they represent. They're gamma-encoded —a curve sRGB applies to make better use of the bits in dark tones, where the eye distinguishes more. A channel value of 128 (half of 255) doesn't emit half the light of 255; it emits considerably less. Before weighing, you have to undo that curve to get back to "linear" light —to the real energy. It's like a photo that comes with a filter applied: before measuring the true colors, you remove the filter.
So the algorithm does, in order: removes the gamma filter (linearizes), weighs the channels by the eye's sensitivity (luminance), and only then compares the two luminances as a ratio. Each step corrects one way "the color's raw number" differs from "the brightness the eye perceives".
Hold on to the image: a color's value isn't the light it emits; the algorithm first undoes the gamma encoding (linearizes), then weighs the channels the way the eye does (green 71%, red 21%, blue 7%), and that's how it gets the real luminance contrast compares.
Worked example: the real algorithm, verified
Here's the complete contrastRatio, in the four steps, with no libraries. Read it with the analogy in mind: linearize undoes the gamma, relativeLuminance weighs, contrastRatio compares. And we verify it against values that have to come out exact —if #000000 over #ffffff doesn't give 21, the algorithm is wrong.
// L6 - WCAG's REAL contrast algorithm. Four steps, no libraries.
// 1) hex -> RGB channels in 0..255.
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
// 2) sRGB linearization: normalizes the channel to 0..1 and undoes the gamma correction.
function linearize(channel255) {
const c = channel255 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
// 3) relative luminance: how much light the color emits to the human eye (green weighs more).
function relativeLuminance(hex) {
const [r, g, b] = hexToRgb(hex).map(linearize);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// 4) contrast ratio: (lighter + 0.05) / (darker + 0.05).
function contrastRatio(fg, bg) {
const L1 = relativeLuminance(fg);
const L2 = relativeLuminance(bg);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// round to 2 decimals to read the ratio as "N.NN:1".
const r2 = (x) => Math.round(x * 100) / 100;
console.log('=== verification against known values ===\n');
console.log('#000000 over #ffffff : ' + r2(contrastRatio('#000000', '#ffffff')) + ':1 (should be 21)');
console.log('#ffffff over #ffffff : ' + r2(contrastRatio('#ffffff', '#ffffff')) + ':1 (should be 1)');
console.log('#808080 over #ffffff : ' + r2(contrastRatio('#808080', '#ffffff')) + ':1 (medium gray, midway)');
console.log('#767676 over #ffffff : ' + r2(contrastRatio('#767676', '#ffffff')) + ':1 (AA\'s boundary gray)');
What to expect. Running the file with Node, the output is exactly this:
=== verification against known values ===
#000000 over #ffffff : 21:1 (should be 21)
#ffffff over #ffffff : 1:1 (should be 1)
#808080 over #ffffff : 3.95:1 (medium gray, midway)
#767676 over #ffffff : 4.54:1 (AA's boundary gray)
Read the four lines as proof the algorithm is correct —not a teaching model, the real one. The first is the golden check: pure black over pure white gives exactly 21:1, contrast's maximum possible. If your implementation gave 20.9 or 21.3, some step would be wrong (a wrong gamma constant, a shifted weight). It gives a clean 21, so all four steps are right. The second: white over white gives 1:1, the minimum —two identical colors, equal luminances, (L+0.05)/(L+0.05)=1. Everything else lives between those two extremes.
The third line takes apart a common intuition. Gray #808080 is "the middle gray" in channel value (128, exactly half of 255). You'd expect its contrast against white to give something near the middle of the range, like 10:1. But it gives 3.95:1 —much closer to the low end. Why? Because of gamma: channel 128 is not half the light; linearized, it gives a luminance well below 0.5, so the "middle" gray is, in real light, closer to white than its number suggests, and it contrasts little with it. This is exactly the analogy's point: the color's value deceives; luminance doesn't. An #808080 over white fails AA for normal text (3.95 < 4.5), even though "128 over 255" sounds like a lot of contrast.
The fourth is a value worth memorizing: #767676 over white gives 4.54:1 —the lightest gray that still passes AA for normal text. It's a real reference number (you'll see it cited in tools): if you need gray text over white that passes AA, it can't be lighter than #767676. Any gray with a higher (lighter) channel value fails.
A question to make sure you understand the gamma: why does the algorithm have that c <= 0.03928 ? c/12.92 : ... branch? (Because sRGB's curve isn't a pure power: in very dark tones (normalized channel ≤ 0.03928) it uses a linear segment (c/12.92), and for the rest, the power ((c+0.055)/1.055)^2.4. It's sRGB's exact definition —a straight segment near black, a curve for the rest. Omitting the linear branch would give slightly wrong luminances for dark colors, and #000000 over #ffffff would no longer give a clean 21. That's why it's there: it's what makes the algorithm the real one and not an approximation.)
Going deeper: where every constant comes from, and the +0.05
None of the algorithm's constants are arbitrary; each one comes from the sRGB or WCAG specification. It's worth knowing what each one is so you don't "clean them up" by mistake:
/ 255: normalizes the channel from0..255(as it comes in the hex) to0..1, where the gamma formula operates.0.03928and12.92: the threshold and slope of sRGB's linear segment near black. Below that point, the encoding is a straight line (c/12.92).0.055,1.055,2.4: the parameters of sRGB's curve for the rest of the range.2.4is the gamma exponent;0.055is a small offset to join up with the linear segment.0.2126,0.7152,0.0722: the luminance coefficients —how much each channel contributes to perceived brightness. They add up to 1 (0.2126 + 0.7152 + 0.0722 = 1.0). They come from the Rec. 709 video standard, which models the eye's sensitivity. Green dominates; blue barely counts.+ 0.05: the most curious detail. It gets added to both luminances before dividing. It models ambient light —the environment's reflection on the screen, which never lets black be perfectly black in real life. Without it, a perfect black (luminance 0) over anything would give an infinite ratio (division by zero). The0.05caps the maximum ratio at(1+0.05)/(0+0.05) = 21, which is why contrast's ceiling is 21 and not infinite. That21from the first verification line comes from the+0.05.
Understanding this protects you from the most common mistake when implementing contrast: copying the algorithm "almost right". If someone omits the linear branch, or puts 2.2 instead of 2.4 (the "generic" gamma instead of sRGB's), or forgets the +0.05, the algorithm almost works —it gives plausible numbers— but doesn't match the official tools, and a pair that should pass AA might get reported as failing, or vice versa. The check against 21:1 is your safety net: if that case doesn't give a clean 21, something here is wrong.
One channel's pipeline, from hex to light:
"#808080" --hexToRgb--> 128 (channel value, 0..255)
│ / 255
▼
0.502 (normalized, 0..1 — apparent "middle")
│ linearize (undoes gamma)
▼
0.216 (the channel's luminance — much less than 0.5!)
│ weight with G=0.7152, etc.
▼
relativeLuminance('#808080') = 0.216 (gray, all 3 channels equal)
│ against L(white)=1.0
▼
(1.0 + 0.05) / (0.216 + 0.05) = 3.95 : 1
Common mistakes
Using the channel average (or a gamma of 2.2) instead of the real algorithm. What happens: "lightness" gets calculated as (r+g+b)/3, or a generic gamma of 2.2 gets used, and the ratios don't match the official tools. Why it happens: the average is intuitive and 2.2 is the gamma "from memory". How to spot it: #000000 over #ffffff doesn't give exactly 21:1, or a pair near the threshold falls on the wrong side. How to fix it: use the exact formula —sRGB linearization with its linear segment and its 2.4 exponent, and the 0.2126/0.7152/0.0722 coefficients. The average ignores that the eye weighs green seven times more than blue (that's why it lied in lesson 4); gamma 2.2 is close but isn't sRGB. "Almost the algorithm" doesn't work when the legal verdict (passes/doesn't pass AA) depends on the second decimal.
Forgetting the + 0.05 (or putting it on only one side). What happens: L1 / L2 gets calculated without the +0.05, and a pure black gives a division by zero (or Infinity), or the ratios come out inflated. Why it happens: the +0.05 looks like a "detail" that can be skipped. How to spot it: your maximum ratio isn't 21 but a huge number or Infinity, or the ratios don't match the tools. How to fix it: the +0.05 goes on both luminances, before dividing, always. It models ambient light and is what makes the ceiling 21 (not infinite). It's part of the official formula, not decoration.
Confusing which is fg and which is bg (and believing it matters for the ratio). What happens: you're unsure whether to pass the text or the background first. Why it happens: the function takes (fg, bg) and you assume the order changes the result. How to spot it: there's no visible error —and that's the point. How to fix it: for the ratio, order doesn't matter: the algorithm takes the max and min of the two luminances, so contrastRatio(a, b) === contrastRatio(b, a). White text over a blue background gives the same ratio as blue over white (you'll see it in lesson 7: both 5.17:1). What does matter is measuring against the text's real background (lesson 5's mistake), not which one you name fg. Contrast is symmetric; readability depends on the pair being the correct one.
Exercises
Exercise 1 — Trace the pipeline. Without running anything, answer about relativeLuminance('#ffffff') (pure white):
- (a) What does
hexToRgb('#ffffff')give? - (b) What does
linearize(255)give? - (c) What's white's relative luminance worth, and why?
See solution
- (a)
[255, 255, 255]—all three channels at maximum. - (b)
1. Normalized:255/255 = 1. Since1 > 0.03928, it goes through the curve:((1 + 0.055)/1.055)^2.4 = (1)^2.4 = 1. Maximum white linearizes to 1. - (c)
1.0. All three linearized channels are worth 1, so0.2126·1 + 0.7152·1 + 0.0722·1 = 1.0(the coefficients add up to 1). White has maximum luminance, 1 —makes sense: it's the color that emits the most light. That's whycontrastRatio(black, white) = (1+0.05)/(0+0.05) = 21.
Exercise 2 — Predict the verdict. The example showed #767676 over white gives 4.54:1. Without running anything, and knowing a lighter gray has less contrast with white: does #808080 text (which gives 3.95:1) over white pass AA for normal text? What about for large text?
See solution
- Normal text: doesn't pass. Normal AA's threshold is
4.5:1, and3.95 < 4.5. It fails —even though "128 over 255" sounds like a lot of contrast, gamma reveals it isn't. - Large text: passes AA. Large AA's threshold is
3:1, and3.95 ≥ 3. A headline in#808080over white is acceptable; a paragraph in the same gray isn't.
This is the intersection of lessons 5 and 6: lesson 6 produces the number (3.95), lesson 5 compares it against the threshold matching the size. #808080 is the perfect example of a gray that "looks middling" but sits below the minimum for normal text.
Exercise 3 — Why you verify. A coworker implemented their own contrastRatio and reports that #000000 over #ffffff gives them 18.4:1. Without running anything, answer: (a) how do you know with certainty their algorithm is wrong? (b) what two algorithm mistakes could produce a value that far off?
See solution
- (a) Because pure black over pure white has to give exactly
21:1—it's contrast's defined maximum, fixed by the+0.05in the formula. Any value other than 21 for that pair proves the implementation deviates from the real algorithm. It's the golden check precisely because its correct answer is known and exact. - (b) Two typical candidates: (1) using an incorrect gamma (generic
2.2instead of sRGB's2.4, or omitting thec/12.92linear segment), which shifts black's or white's luminance; (2) forgetting or duplicating the+0.05, or applying it to only one side, which changes the ratio's ceiling. Either one gives a number that's "plausible but wrong". The lesson: without verifying against21:1, an almost-correct algorithm goes unnoticed —and reports pairs as accessible when they aren't.
Summary and next step
In this lesson you implemented WCAG's real contrast algorithm in four steps: hex → RGB, sRGB linearization (undoing gamma, with its linear segment and its 2.4 exponent), relative luminance (weighing green 71%, red 21%, blue 7%), and the ratio with (lighter + 0.05)/(darker + 0.05). With the eye that doesn't count light "raw", you saw why each step exists: a color's value is gamma-encoded and the eye weighs channels differently, so you have to linearize and weigh before comparing. And you verified it against golden values: #000000 over #ffffff gave a clean 21:1, #ffffff over itself 1:1, and the "middle" gray #808080 only 3.95:1 —proof that a color's value deceives and luminance doesn't.
Before moving on you should be able to: name the algorithm's four steps and what each one corrects; explain where the 21 ceiling comes from (the +0.05); and say why verifying against #000/#fff = 21:1 proves the implementation is the real one.
You now have the machine that produces the number and know the threshold it has to beat (lesson 5). Lesson 7 brings them together for what really matters: choosing accessible color pairs. You're going to audit product-card's real pairs —the text over the card, the button, the price— with contrastRatio and passesWCAG, find the one that fails (a gray too light), and fix it by moving up a step on the ramp. It's this lesson's algorithm put to work on lessons 2–4's colors.
Resources
- W3C, "Relative luminance" (WCAG 2.1 definition) — w3.org/WAI/GL/wiki/Relative_luminance. The exact formula you implemented, with its constants; the authoritative source for verifying every step. In English.
- W3C, "Contrast ratio" (definition) — w3.org/TR/WCAG21/#dfn-contrast-ratio. The normative definition of the ratio
(L1+0.05)/(L2+0.05), including where the+0.05comes from. In English. - MDN, "Understanding relative luminance" — via developer.mozilla.org, "WCAG color contrast". The explanation of why the eye weighs channels differently; the analogy's foundation. In English.
- WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. The reference tool to verify your ratios against; test
#000000/#ffffffand confirm your21:1. In English.