How to calculate the contrast ratio.
The contrast ratio is the ratio of the relative luminances of two colors, taking a value between 1:1 (same color) and 21:1 (black on white).
Contrast is where eyeballing goes wrong most often in accessibility. That two colors look “different enough” says nothing: white text on bright yellow looks vivid to the eye, measures 1.1:1, and cannot be read. That is why contrast is not judged — it is calculated.
The formula: relative luminance
The calculation has two steps. First the relative luminance of each color is found, then the ratio of the two is taken. Relative luminance is the brightness of a color as perceived by the human eye, and the channel weights are not equal — green carries far more weight than red and blue.
- Bring each channel into the 0–1 range: divide the value by 255.
- Remove the gamma correction: if the value is below 0.03928, divide by 12.92; otherwise raise (value + 0.055) / 1.055 to the power 2.4.
- Sum with weights: 0.2126 × red + 0.7152 × green + 0.0722 × blue.
- Find the ratio: (lighter + 0.05) / (darker + 0.05). The result is between 1 and 21.
Green carrying a weight of 0.7152 may seem counterintuitive, but it reflects the dominance of the eye’s green-sensitive cone cells. The practical consequence: for a designer wanting to darken a palette, the most effective channel is green — and if two colors have similar green values, the contrast between them stays low, however different their red and blue are.
The 0.05 constant in the formula is no accident either: it represents the assumption that screens never produce absolute black and there is always some ambient reflection. That is why the ratio is never infinite; its ceiling is 21:1.
The thresholds
WCAG defines three separate thresholds, and which one applies depends on the size of the text and the type of content.
- Normal text, AA (1.4.3) — 4.5:1.
- Large text, AA (1.4.3) — 3:1. Large text: 18.66px bold, or 24px regular and above.
- Normal text, AAA (1.4.6) — 7:1.
- Large text, AAA (1.4.6) — 4.5:1.
- Interface components and meaningful graphics, AA (1.4.11) — 3:1. Not the text — the BOUNDARY is measured.
- Disabled components and pure decoration — no threshold, out of scope.
The large-text exception is often misused. The 18.66px bold boundary corresponds to 14pt at the browser default of 16px; in a heading scaled with clamp, the value changes with viewport width and can fall below the threshold on a narrow screen. In scaled typography the safe path is therefore not to lean on the large-text exception at all.
A measured example
To keep it concrete, this site’s own palette pairs. The values were computed with the formula above, and the verification suite recomputes them on every run — if a color drifts, the test turns red.
- Body text / paper ground — 18.15:1. The AAA threshold is 7:1; two and a half times above it.
- Secondary text / paper ground — 7.07:1. Just above the AAA threshold, a deliberate choice.
- Accent color / paper ground — 10.22:1. Links and the focus ring use this color.
- Body text / card ground — 16.01:1.
- Accent on dark / ink — 4.55:1. Just above the AA threshold of 4.5:1; the dark band deliberately uses a different tone.
- Error color / paper ground — 6.25:1. Only in form errors; never in decoration.
The secondary text measuring 7.07:1 is notable: the value is only one percent above the AAA threshold. This gray was chosen by calculation, not by chance — a lighter gray might have been aesthetically preferable but would have fallen below the threshold. Contrast worked here as a design constraint.
Common mistakes
A few mistakes recur in contrast calculation; each invalidates the measurement.
Measuring through transparency. A semi-transparent text color blends with the ground beneath it, and the real color is not the computed one. The measurement must use the FINAL pixel value the browser computes — which is why serious audits read from the live page, not from a screenshot.
Assuming the wrong ground. If text sits on an image, a gradient or a video frame, there is no single background color. The worst case must be measured, not the average — and in practice the right fix is placing a mask layer over the ground to stabilize it.
Skipping hover and focus states. A link’s normal state can measure 7:1 while its hover tone drops to 3:1. Every interaction state is a separate color pair and must be measured separately.
Contrast is a property of a STATE, not of a color pair.
Finally: measuring contrast once and moving on is not enough. When the palette file is edited, the measurement goes stale. The lasting fix is binding the pairs to a test — the moment a value falls below the threshold, the deployment turns red instead of depending on anyone’s eye.
Dark theme is a separate palette
A dark theme is not the light theme’s colors inverted, and for contrast it demands an entirely separate calculation. The reason is physical: light text on a dark ground is perceived differently from dark text on a light ground — thin typefaces optically thicken on dark grounds, and pure white text produces halation.
The practical consequence: using pure white for body text in a dark theme looks tempting because the contrast math returns 21:1, but it lowers reading comfort. A slightly broken white — its luminance reduced by a few percent — stays far above the threshold and does not tire the eye.
The accent color needs separate treatment too. A dark blue measuring 10:1 on a light ground drops to 1.5:1 on a dark ground and becomes unreadable. Serious palette systems therefore define a separate accent tone for dark grounds. This site does exactly that: the light accent tone used in the dark band measures 4.55:1 against the ink and stays above the AA threshold.
Both themes must be measured separately and bound to tests separately. Measuring one theme and assuming “the other will hold too” is the most frequent and most easily caught mistake.
How to automate the measurement
The problem with measuring contrast by hand is that it is not repeatable. A value measured once silently goes stale at the next touch of the palette file. The lasting fix is binding the measurement to code, and it has two layers.
- The token layer — palette pairs are read from the source file and computed with the formula. Fast, needs no browser, runs on every pass. What it catches: a color drifting below the threshold.
- The render layer — real pixel values are read from the live page. Slow, needs a browser. What it catches: transparency, stacked layers, gradients and hover states — everything the token layer cannot see.
- Both together — the token layer on every run because it is cheap; the render layer for the critical screens. Neither substitutes for the other.
The distinction matters because most teams build only the first and stay blind to the class of errors the second catches. Text under a transparent layer looks flawless in the token math; on screen it cannot be read. The reverse holds too: a team measuring only at render cannot see a broken pair sitting in the palette that no page uses yet.
On this site both layers are in place. The token layer computes seven palette pairs on every run; the render layer reads real pixels on the critical screens. The second layer’s existence is no accident — it was added after a defect was caught by measurement, and that story is told on the non-text contrast page.
Contrast measured once goes stale at the next palette edit. A measurement not bound to code counts as not done.