Contrast ratios, and how to read one without doing the maths
The part everyone skips is the linearisation inside the formula. Here is the whole calculation for one grey on white, and what the number cannot see.
Every accessibility audit arrives at the same number, and the number is almost always quoted without
the working. A designer is told that #767676 on white passes and #777777 does
not, which is true, and nobody explains why the line falls between two greys that look identical in a
swatch. The arithmetic is short, and doing it once by hand changes how you pick colours afterwards.
What the ratio actually compares
The whole definition is one expression:
contrast ratio = (L1 + 0.05) ÷ (L2 + 0.05) L1 relative luminance of the lighter of the two colours L2 relative luminance of the darker
Both luminances are numbers between 0 and 1. Putting the lighter one on top is not a stylistic choice. It guarantees the ratio never drops below 1, and it makes the number symmetric: swap which colour is the text and which is the background, and the ratio does not move.
The 0.05 added to each side is a floor, there to account for the glow a display gives off and to keep the darkest possible pair from dividing by zero. It is also why the best ratio available is 21:1 rather than infinity. Black on white works out as (1.0 + 0.05) ÷ (0.0 + 0.05), which is 1.05 ÷ 0.05, which is 21.
Relative luminance is not brightness
Here is the sentence that makes the formula look harder than it is. L is not how bright a colour appears. It is a weighted sum of the three sRGB channels, and before the weights are applied each channel has to be put through a transfer function that undoes the gamma encoding the display applies.
That middle step is the one people skip, and skipping it is fatal. sRGB channel values are not proportional to light. A channel sitting at 128 emits roughly a fifth of the light a channel at 255 does, not half. Averaging the raw numbers produces a luminance that is wrong by enough to move a colour across a threshold. To undo the encoding, divide each channel by 255 to get a value between 0 and 1, then put it through a piecewise function: a short straight line near the bottom of the range, and a power curve everywhere above it.
c = channel value ÷ 255 (0 to 1) if c is 0.04045 or less: c' = c ÷ 12.92 otherwise: c' = ((c + 0.055) ÷ 1.055) ^ 2.4 luminance = 0.2126 × R' + 0.7152 × G' + 0.0722 × B'
The straight-line branch exists for numerical reasons rather than perceptual ones, and it is reached only by channel values of 10 or below. Everything from the visible dark greys upwards takes the power curve, which is the half of that block worth remembering.
The three weights are why the formula surprises people. Green carries 0.7152 of the total, red 0.2126, blue 0.0722. The numbers come from the response of the three cone types in a standard observer, and the consequence is easy to say in words: a colour's luminance is mostly a statement about how much green is in it. Move the green channel and the number travels a long way. Move the blue channel and it barely stirs. Against white, the three primaries land like this.
Red lands at 4.00 and fails AA for body text. Green lands at 1.37, below every threshold in the standard and close to unreadable. Blue lands at 8.59 and sails past AAA without trying. None of that is guessable from looking at the colours, because the eye weighs hue as well as lightness and the formula weighs lightness alone.
One colour, worked all the way through
Take #767676, a grey that sits in a lot of design systems because it is the lightest one
that still clears body-text contrast on white. Here is the whole calculation, with nothing skipped.
Split the hex into channels. #767676 is R 118, G 118, B 118. All three
channels are equal, so this is a neutral grey and the weights will sum to 1. That is a convenience, not
a shortcut — the same steps run for any colour you like.
Normalise one channel. 118 ÷ 255 = 0.4627.
Undo the encoding. 0.4627 is well above 0.04045, so the power branch applies. 0.4627 + 0.055 = 0.5177. Divide by 1.055 and you get 0.4908. Raise that to the power of 2.4 and you get 0.1812.
Apply the weights. All three channels produced 0.1812, so the sum is 0.1812 × 0.2126 plus 0.1812 × 0.7152 plus 0.1812 × 0.0722. The weights add to 1.0000, so the luminance is 0.1812.
Do the same for white. 255 ÷ 255 = 1.0, which is above the cut-off, so 1.0 + 0.055 divided by 1.055 is 1.0, and 1.0 raised to 2.4 is still 1.0. The luminance of white is 1.0.
Divide. (1.0 + 0.05) ÷ (0.1812 + 0.05) = 1.05 ÷ 0.2312 = 4.542.
That rounds to 4.54, which is past 4.5. Now run the same steps on #777777, one step darker
in the 8-bit range. Its channel value is 119, which normalises to 0.4667, which becomes 0.4945, which
linearises to 0.1845. Its luminance is 0.1845 and its ratio is 1.05 ÷ 0.2345 = 4.478, or 4.48.
Two greys that nobody can tell apart in a swatch, one step apart in the 8-bit range, sitting on
opposite sides of the line. #767676 clears AA by 0.04, which is not a margin worth
defending. If a colour of yours lands between 4.5 and 4.6, darken it and stop thinking about it.
Which threshold applies to what
The thresholds are not one number. Each is attached to a different situation, and the table below is the whole of it as it appears in WCAG 2.2.
| Ratio | Level | What it covers |
|---|---|---|
| 4.5:1 | AA | Body text under 18pt, or under 14pt when bold |
| 3:1 | AA | Large text: 18pt and up, or 14pt and up when bold |
| 7:1 | AAA | Body text, at the enhanced level |
| 4.5:1 | AAA | Large text, at the enhanced level |
| 3:1 | AA | Non-text: control borders, icons, focus indicators, chart strokes |
Read the ratio and level columns together: the same 3:1 appears twice for entirely different reasons. Once it is the relaxed bar for text large enough to survive less contrast, and once it is the bar for things that are not text at all.
AA is the level most organisations actually have to meet, because it is the one written into
procurement rules and legal settlements. AAA is stricter and most sites do not attempt it site-wide,
because 7:1 for body text is a real constraint. Against white, only greys at #595959 or
darker clear it.
Large text means 18pt and above, or 14pt and above when bold. At the 96 pixels per inch that CSS assumes, that is 24px, and 18.66px for the bold case. The reasoning is that thicker strokes are easier to resolve, so the eye tolerates less contrast. It is a real effect, and the thresholds reflect it rather than approximating it.
The non-text rule is the one that gets missed, and it is the one that catches icon buttons with no visible border, focus rings drawn in a pale tint of the brand colour, chart strokes that vanish at print size, and the outline of an input field that only appears on hover. All of those are held to 3:1, and all of them are easy to leave sitting at 1.4:1 without noticing.
A shelf of greys you can pick by eye
The table below is the arithmetic done for a set of greys against white, so you can choose one without rerunning it. Every value in the middle column comes from the same calculation as the worked example.
| Grey | Ratio on white | What it clears |
|---|---|---|
| #000000 | 21.00 | Everything, several times over |
| #333333 | 12.63 | Every threshold here, with room to spare |
| #595959 | 7.01 | AAA body text, by a hair |
| #666666 | 5.74 | AA body text, comfortably |
| #767676 | 4.54 | AA body text, by 0.04 |
| #777777 | 4.48 | Large text only |
| #808080 | 3.95 | Large text, and non-text marks |
| #969696 | 2.96 | Nothing: it misses 3:1 as well |
| #AAAAAA | 2.32 | Nothing |
| #DDDDDD | 1.36 | Nothing; unusable as text on white |
Against #FFFFFF. Anything at or below 4.48 in the middle column cannot carry body text on white, whatever it looks like in a swatch.
Two rows are worth memorising. #767676 is the lightest grey that clears 4.5 against white,
at 4.54. #595959 is the lightest that clears 7, at 7.01, and it is the one to reach for if
you want a soft grey that will not embarrass you at AAA. Everything lighter than #767676
is decoration on white: it can be a border or a divider, and it cannot be a paragraph.
One asymmetry to know before you invert a palette. The ratio for a pair of colours is the same
whichever of the two is the text, but inverting both ends of the pair does not preserve it, because the
0.05 is added to both sides. #767676 on white is 4.54. Its counterpart, #898989
on black, is 6.00. It is tempting to assume a dark theme keeps the numbers and only changes the
scenery. It does not, and a grey that was safe in light mode is often not safe in dark mode.
What the number cannot see
A passing ratio is a floor, and treating it as a verdict is where most of the trouble comes from. The formula takes two colours and returns one number, and here is what that number is blind to.
It does not know how big the text is inside the normal-text band. Anything under 18pt is held to the same 4.5, whether it is a 12px caption or a 17.9px paragraph. The standard draws one line, and the formula cannot see across it.
It does not know how heavy the face is. Font weight is not an input. A 16px hairline at exactly 4.5 and a 16px bold at exactly 4.5 produce the same number and are not the same reading experience. Stroke thickness, counters and letter-spacing all change legibility, and none of them reach the formula.
A thin light face at 4.5 is a pass on paper and a struggle in practice. This is the
failure I see most often. A 5.0 ratio in a 300-weight typeface on a bright screen is harder to read
than a 4.6 in a sturdy text face. My working rule is to want 7:1 for any face I would describe as
light, and never to set a light-weight face in a grey lighter than #595959. Those are my
numbers, not the standard's.
It knows nothing about colour blindness. The formula collapses a colour to a single luminance value, so two colours with the same luminance are identical in the arithmetic, whatever hues they carry. A red error message and a green success message tuned to the same luminance will pass every contrast check on the page and be indistinguishable to a reader with deuteranopia, because the difference between them is hue, and hue is the thing the formula threw away. Contrast and colour-independence are separate problems.
It cannot read text over an image or a gradient. The formula needs two colours, and a photo has no single colour to give it. A gradient has a different answer at every point along it, and the ratio you compute will be the ratio at the one pixel you happened to sample. A translucent panel or a blurred backdrop changes the effective background under the text, so the value you measured before compositing is not the value the reader sees. For those cases the ratio is not a weak tool, it is the wrong one.
There are formal exemptions too — disabled controls, logotypes, and text that is purely decorative — which exist because the people who wrote the standard knew the formula was not measuring those things.
Where the tools fit. The Contrast Checker takes a
foreground and a background and shows the ratio with the working broken out — the linearised channel
values, the luminance of each colour, the division — so you can watch the steps above happen to a
colour of your own rather than to #767676.
If you are still choosing the colour, the Colour Studio is where the picking happens: build the ramp, then take the two ends you intend to pair and put them through the checker before anything is written into a stylesheet.
The habit worth keeping
Learn one number: #767676 on white, at 4.54. It is the boundary case, so it calibrates
everything else. Once you know it, you can place a grey above or below the line with reasonable
accuracy, and you know that the line is finer-grained than the eye is.
Then build the green intuition. When a colour fails a check you expected it to pass, look at how much green is in it. A saturated green, a lime, a yellow-green, a bright cyan — those are the colours that come out far lighter than they look, because 0.7152 of the luminance belongs to the green channel. When a colour passes more comfortably than you expected, it is usually blue, which carries almost nothing.
And treat the ratio as what it is: a floor under the worst case, not a description of a good page. If your body text clears 4.5 by 0.04 you have met the rule and built something fragile. Take the grey a step darker, spend the contrast where it costs you nothing, and keep the pale greys for dividers and disabled states. The way to check your own case is to take the lightest grey text on your site and run it, then take the second lightest and run that. In most designs I have looked at, the second lightest is the one that fails.