A caption set in #999999 on a white card looks quiet and expensive on a good monitor in a dim room. It measures 2.84:1. On a phone at forty percent brightness with the sun somewhere behind the reader, that caption is a grey smear, and nobody writes in to complain about it. They just stop reading.
Contrast is the one accessibility metric that is not a matter of taste. Alt text quality is a judgement call. Heading order takes a conversation. Contrast is arithmetic: two colours go in, one number comes out, and that number either clears a threshold or it does not. Nobody gets to have an opinion about it in a design review.
The number is a ratio between the relative luminance of two colours. It runs from 1:1, the same colour twice, up to 21:1, pure black on pure white, and nothing outside that range exists. WCAG 2.x asks for 4.5:1 for normal text, 3:1 for large text, and 7:1 if you are chasing AAA.
The checker below does that arithmetic, shows all four verdicts at once, and when a pair misses it walks the lightness of one of your two colours until it finds the nearest value that passes. It runs entirely in your browser: nothing is uploaded and nothing is stored.
One number, two colours
The ratio does not know which of your colours is the text and which is the background. The formula takes the lighter luminance first and the darker one second, so swapping the pair returns exactly the same number. The Swap control in the tool exists so you can look at the pair the other way round in the preview, not because the maths changes.
It also knows nothing about hue. Two colours can be violently different, a saturated red and a saturated green for instance, and still sit at something like 1.1:1, a flat unreadable field for everybody. Contrast is a question about light, not colour. Hue is a separate problem with its own section below.
What it does tell you, reliably, is whether a letterform will separate from what sits behind it for a reader with roughly 20/40 vision, the level of acuity the 4.5:1 threshold was calibrated against. A narrow claim, and a testable one, which is why it is the number worth learning.
Contrast checker
Check a text colour against a background the way WCAG does, then fix it in one click. Everything is worked out in this browser tab, no colours are uploaded anywhere.
That is not a colour I can read, so the last good one is still in use.
That is not a colour I can read, so the last good one is still in use.
Large text means 24 px, or 19 px when it is bold. The ratio is rounded down, so it never flatters a pair.
A heading at 24 px
Bold at 19 px, which already counts as large text.
Body copy at 16 px. Most of a page is set at this size, so this is the line that decides whether the page is comfortable to read for an hour or only for a minute.
The preview is redrawn as that visitor would see it. The badges keep using your real colours, which is what WCAG measures.
Relative luminance is not the average of the channels
Every contrast ratio is built in three steps: undo the display gamma on each channel, weight the three channels by how much light the eye gets from them, then compare the two results with a fixed offset.
// one channel, 0 to 255, back to linear light
c = c8 / 255
lin = (c <= 0.03928) ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
// relative luminance of one colour
L = 0.2126 * linR + 0.7152 * linG + 0.0722 * linB
// contrast, lighter luminance first
ratio = (L1 + 0.05) / (L2 + 0.05)
Two things in there do all the damage. The first is the gamma step. A hex value of 128 is not half the light of 255; run it through the curve and it comes out at about 0.216 of full channel output. That single line is why mid grey #808080 on white measures 3.94:1 rather than something comfortably in double figures, and why so many designs that look like they are halfway between the extremes are nowhere near it.
The second is the weighting. Green contributes 71.52 percent of the luminance, red 21.26 percent and blue 7.22 percent, because that is roughly how the eye’s cones divide the work. An average of the channels would treat pure blue and pure green as equally bright. They are nothing like it.
Read the table and the failures start to look inevitable. Pure yellow carries 92.78 percent of white’s luminance, so white text on it lands at 1.07:1, barely distinguishable from painting the text in the background colour. Pure blue carries 7.22 percent, so it behaves almost like black: white text on it clears 8.59:1, while black text on it fails at 2.44:1. Blue and yellow are the two colours people most reliably get the wrong way round.
The + 0.05 in the last line is a flare constant. It models the ambient light bouncing off the front of a screen, the light that never came from the pixel at all. It also keeps the denominator away from zero, which is why the scale stops at 21 instead of running to infinity: white sits at luminance 1, black at 0, and 1.05 divided by 0.05 is 21.
The large text exception, precisely
Success criterion 1.4.3, Contrast (Minimum), is the AA rule: 4.5:1 for text, relaxed to 3:1 for large text. Criterion 1.4.6, Contrast (Enhanced), is AAA: 7:1, relaxed to 4.5:1 for large text. Four numbers, and the only tricky part is the word “large”.
WCAG defines large scale text as at least 18 point, or 14 point when bold. Points are a print unit, so they have to be converted: at the 96 pixels per inch the CSS spec assumes, one point is 1.333 px. That makes 18 pt exactly 24 px, and 14 pt bold 18.66 px, which browsers and tooling normally round to 19 px. Those two numbers are why the preview panel in the checker renders sentences at exactly 24 px, 19 px bold and 16 px.
The mistake almost everyone makes is assuming bold alone buys the exception. It does not. Bold text at 18 px is under the 18.66 px line and is measured as normal text, needing the full 4.5:1. A 20 px regular heading is normal text too. Meanwhile a 24 px regular subhead qualifies as large at any weight. Size is the rule; weight only moves the size boundary down.
One more detail that saves arguments: these are CSS pixels, not device pixels. A phone with a 3x display ratio is still rendering 16 px body copy at 16 CSS px, so its classification does not change. What changes on a phone is everything else.
Why a pair that passes on your monitor fails in the wild
The formula assumes a screen viewed in reasonable conditions. That 0.05 flare constant represents a modest amount of ambient light landing on the display. Outdoors it is not modest. Sunlight hitting a phone raises the black level dramatically, because the glass is reflecting far more light than the dark pixels are emitting, and every ratio on the screen collapses towards 1:1. A 4.6:1 pair in your office can behave like a 2:1 pair on a bus.
Then there is auto brightness dropping the panel to a third of its output, night shift and warm colour temperature filters pulling blue out of the picture, cheap laptop panels whose blacks are really dark grey, and viewing angle shifts on older screens that push mid greys around by a noticeable amount. None of that is modelled. On top of it, an image with a stripped or mislabelled colour profile can be painted with different values than you exported, which is a separate class of problem worth understanding on its own.
The practical conclusion is not to distrust the number. It is to stop treating 4.5:1 as a target. It is a floor, measured in favourable conditions. Body copy at 7:1 or better costs nothing and survives daylight, a dying battery and a reader who left their glasses at home. Save the values that sit just above the line for the things that are genuinely decorative.
The pairs that fail most often
- White on yellow or orange. White on
#FFFF00is 1.07:1. White on a strong orange like#FF6600is 2.93:1, so it fails even the relaxed large text threshold. Both colours want black text: black on that yellow is 19.55:1, black on that orange is 7.15:1, comfortably AAA. Warning banners and sale badges get this wrong constantly. - Mid grey on white. The boundary is sharper than anyone expects.
#767676on white is 4.54:1 and passes AA.#777777is 4.47:1 and fails. One step of one hex digit decides it, which is why guessing is hopeless and measuring takes two seconds. - Brand colours used for body text. A friendly mid blue like
#4A90D9gives 3.34:1 on white. Fine for a 24 px heading, fails for a paragraph, and it is usually the paragraph that matters. - Placeholder text in form fields. Browser defaults live in the danger zone: the classic
darkgrayplaceholder colour,#A9A9A9, is 2.35:1 on white. If the placeholder is carrying real information, it needs to pass like any other text, and it probably should have been a visible label instead. - Text over a photograph. There is no single background colour, so there is no single ratio. Measure against the lightest and the darkest pixel that actually falls behind the letters, or put a solid plate or scrim under the text and measure against that instead. Getting type to stay readable over a picture is mostly a question of building yourself a background you can measure.
When the brand colour fails
This is where most contrast conversations go wrong, because the instinct is to argue with the threshold. Change the text colour, not the standard. The brand colour keeps its exact hex in the places where the contrast rules do not bite, and takes a darker variant in the places where they do.
Those places are more generous than people assume. Logotypes are explicitly exempt from 1.4.3, so the mark itself never has to change. Large display type only needs 3:1. A colour used as a fill behind white text is judged on that pair, not on the fill against the page. Borders, icons and chart segments fall under 1.4.11, Non-text Contrast, which asks for 3:1 against what is next to them. What the brand colour usually cannot be is 16 px running text on white.
The fix walk in the checker exists for exactly this. Give it the failing pair, choose which of the two colours may move, and it converts that colour to HSL, holds the hue and the saturation, and steps the lightness by half a percent at a time in each direction, testing the real ratio at every step and stopping at the first value that clears your target. Feed it #4A90D9 on white against AA normal text and the darker candidate comes back at #2A78C8, 9.5 points of lightness down, reaching 4.54:1. Same hue, same saturation, still recognisably the brand blue.
The lighter button in that example comes back disabled and labelled “nothing works”, because walking a colour lighter against a white background only ever moves it towards white. The walk stops at lightness 0 and 1 and reports honestly instead of returning something that does not pass. In practice you end up with two tokens: the brand colour, and the text colour derived from it, which is how most mature palettes are built anyway.
Contrast and hue are two different problems
About one man in twelve has some form of colour vision deficiency, and contrast ratios do not describe their experience directly. Dichromacy mostly collapses hue, not luminance: a red and a green of similar lightness stop being two colours and become one flat field, even though the measured ratio between them was never good in the first place. Protanopia, the loss of the long wavelength cone, is the exception worth knowing, because it also drops the apparent brightness of reds, so red text on a dark background is worse for a protanope than the number suggests.
The simulate switch in the checker redraws only the preview through protanopia, deuteranopia or tritanopia using the Vienot and Brettel LMS matrices, and adds one line telling you what the pair reads as, with the two simulated hex values. The big number and the four badges never change, because WCAG measures the real colours and a simulated ratio would be a number no standard recognises. Turn the switch off and the true colours come back exactly.
The design rule that falls out of this is older than WCAG: never carry meaning in hue alone. Red for the error and green for the success is fine as long as the error also has an icon, a label or a position that says error. Links need something besides colour, usually an underline. Chart series need direct labels or distinct shapes as well as distinct fills. Getting the contrast right does not solve that, and solving that does not get you the contrast.
What the checker will not do
Every number on screen is rounded down to two decimals, never up. A pair that computes to 4.4999 reads 4.49 with a FAIL rather than showing 4.50 next to a failing badge, and a 3.058 pair reads 3.05. The displayed value is never better than the real one, so the badges can never contradict the number they sit under.
Alpha is ignored on purpose. Paste an eight digit hex or an rgba() and the alpha is dropped, because a translucent colour has no single contrast ratio until you know what is behind it. If you are using a 60 percent black scrim over a photo, composite it yourself and give the tool the resulting solid colour. Twenty common CSS colour names are recognised rather than all 148, and lab(), lch() and oklch() are not parsed at all. Anything it cannot read is flagged in red under the field while the last good colour stays in force, so the preview never blanks out while you are halfway through typing a hex.
Bigger gaps: it checks one pair at a time, so it will not crawl a page and find every failing combination in your stylesheet. It has no view on text over gradients or photographs beyond the plate trick above. And it implements WCAG 2.x, not APCA, the perceptual contrast algorithm being developed for WCAG 3. APCA models thin light text on dark backgrounds better than the current ratio does, but WCAG 3 is still a draft, and conformance today, in every procurement document and legal requirement that exists, is measured with the ratio in this article.
For the rest of the page, the checker is a spot instrument, not an audit. Run a full accessibility pass with something that reads the rendered DOM, then bring the pairs it flags here to work out what to change them to.
The habit worth building
The thing that makes contrast unusual among accessibility problems is that it is decided at design time, by two hex values, before a single line of markup exists. Every other fix happens later and costs more. Choosing #767676 instead of #999999 for your captions is free on the day you pick it and expensive eighteen months later when it has propagated into a component library, a newsletter template and four hundred posts.
So measure early and measure the pairs that repeat: body text on the page background, body text on your card and panel backgrounds, link colour on both, muted text, placeholder text, and white on every brand fill you use for buttons. That is about ten pairs for most sites. Write the passing values down as tokens, and the question stops coming up in reviews, in the same way that settling a few typographic defaults once stops those arguments recurring.
And keep the scope honest. A passing ratio does not make a page accessible, any more than filling in the alt text field does. It means one specific failure mode, text that dissolves into its background, has been ruled out with arithmetic instead of eyesight. That is a small guarantee, but it is a real one, and it is the only part of this whole discipline where you can settle the argument with a number.