You have seen #999 on white a thousand times. Captions, placeholder text, form hints, secondary labels, the small print under a button. It is the default way to say "this is less important".
Its contrast ratio is 2.85:1. The minimum for body text is 4.5:1. It is not marginal — it is roughly 40% short.
Where 4.5:1 comes from
The number is not arbitrary. Research on contrast sensitivity found that a person with 20/40 vision — the threshold for a driving licence in most places, and entirely ordinary past the age of fifty — needs about 1.5 times the contrast of someone with 20/20 vision. The baseline for legible text was taken as 3:1, and 3 × 1.5 = 4.5.
So the threshold answers a specific question: can someone with mildly reduced vision, wearing the wrong glasses or none, read this? That is not an edge case. It is most people over fifty, everyone on a phone in daylight, and anyone on a cheap monitor at an angle.
Large text gets a lower bar of 3:1, because size compensates for contrast. But "large" has a specific definition — 18pt, or 14pt bold, roughly 24px and 18.5px. A 16px semi-bold heading does not qualify, and that exemption is claimed for it constantly.
The formula, and why eyeballing fails
The ratio is (L₁ + 0.05) / (L₂ + 0.05), where L is relative luminance. The 0.05 accounts for ambient light reflecting off the screen, which is why nothing ever reaches infinity — pure black on pure white is 21:1, the maximum.
Luminance is where the intuition breaks. Each channel is linearised first — undoing the gamma encoding that sRGB applies — and then weighted:
L = 0.2126 R + 0.7152 G + 0.0722 B
Green counts for ten times what blue does. Human vision evolved with peak sensitivity in the green part of the spectrum, and the formula reflects that.
The consequence is dramatic and completely counter-intuitive:
- Pure yellow on black — 19.56:1. Passes everything.
- Pure blue on black — 2.44:1. Fails everything.
Both are "a bright saturated colour on black". Both look vivid. One is nearly the maximum possible contrast and the other is unreadable, and no amount of looking at them tells you which is which. Averaging the RGB values — the shortcut a quick mental check performs — gets this exactly wrong.
So measure it. Do not judge it by eye. Try both in the contrast checker and watch the numbers.
The greys that pass
On a white background, this is the whole picture:
#767676— 4.54:1. The lightest grey that passes AA. Worth memorising.#949494— 3.03:1. Passes for large text only.#999999— 2.85:1. Fails everything.#cccccc— 1.61:1. Effectively invisible to a great many readers.#595959— 7.0:1. The lightest grey that reaches AAA.
Nothing lighter than #767676 can carry body text on white. That single fact resolves most contrast disputes before they start.
The four places it goes wrong
Placeholder text. Browsers render it grey by default, and designers then lighten it further. It is often the only label a field has — and once someone starts typing, it disappears entirely, which is a separate and worse problem. Use a real label above the field.
Form field borders. WCAG 1.4.11 requires 3:1 for the parts of an interface that are not text: input borders, icons, focus rings, chart lines. A page whose every word passes can still fail here, and a form whose fields are outlined in #e5e5e5 is a form some people cannot see the boundaries of.
Text over images. The ratio changes across the image. Check the lightest and darkest points the text crosses, not the average — a headline that is legible over the sky and vanishes over the building is the usual result. If either extreme fails, the text needs a scrim behind it.
Disabled controls. WCAG exempts them. That is a compliance exemption, not a usability one — a disabled button nobody can read is still a button nobody can read, and users cannot tell "disabled" from "broken".
Dark mode is not a free pass
Inverting the palette does not preserve the ratios, because luminance is not symmetrical. Pure white on pure black is technically 21:1 but produces halation — the glow that makes text appear to vibrate, particularly for readers with astigmatism. Most well-built dark themes use an off-white around #e8e8e8 on a near-black around #121212 for exactly this reason.
Saturated colours behave differently too. A blue that reads well on white is often far too dark on black and needs lightening considerably. Every pair has to be checked in both themes — there is no transformation that carries the results across.
Hierarchy without lightening the text
The reason grey is reached for is real: secondary information should look secondary. But lightness is only one of several tools, and it is the one with an accessibility floor. The others do not:
- Size. 14px against 16px reads as subordinate at full contrast.
- Weight. Regular against semi-bold, with no colour change at all.
- Space. Distance from the main content signals relationship more strongly than colour does.
- Position. Below and indented is understood as secondary without any styling.
Use #595959 at 14px rather than #999 at 16px. It is quieter in the layout, and everyone can still read it.
Building it into the palette
Auditing colours after the fact never sticks. Generate a scale where the answer is already settled. Build a ramp, measure each step once, and record which steps are safe for text. Then "use 700 for body copy" is a rule anyone can follow without re-deriving it.
The palette generator reports where that line falls for any hue you give it, and the contrast checker will hand you the nearest shade of your own colour that passes — same hue, same saturation, adjusted lightness. That is usually the difference between a fix that gets adopted and one that gets reverted for looking wrong.
The short version
Body text needs 4.5:1, and #767676 is the lightest grey that reaches it on white. Blue contributes almost no luminance, so bright blue on dark fails while bright yellow passes easily — you cannot judge this by eye. Borders, icons and focus rings need 3:1 of their own. And when something needs to look secondary, make it smaller or lighter in weight rather than lighter in colour.