Skip to content
Allin

Contrast Ratio: How WCAG Actually Computes It

Published 5/15/2025 · 15 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 6 sources

View profile
In short

WCAG 2.x defines contrast as (L1 + 0.05) ÷ (L2 + 0.05), where L1 is the relative luminance of the lighter colour and L2 that of the darker. Relative luminance is not brightness and not the L of HSL. Each sRGB channel is divided by 255, then linearised with a piecewise function — below 0.03928 divide by 12.92, otherwise ((c + 0.055) ÷ 1.055) raised to 2.4 — and the three linear values are weighted 0.2126 red, 0.7152 green, 0.0722 blue. Green carries 71.52% of the luminance and blue only 7.22%, which produces the result nobody expects: pure blue #0000ff on white scores 8.59:1 and clears every WCAG level, while mid grey #808080 on white scores 3.95:1 and fails ordinary body text. The 0.05 term is why black on white is 21:1 rather than infinite — black has luminance 0, and 1.05 ÷ 0.05 = 21. The thresholds are 4.5:1 for body text at AA, 3:1 for large text (18pt = 24 CSS px, or 14pt bold = 18.67 px) and for non-text elements, and 7:1 at AAA. WCAG 3 is an early Working Draft proposing a different, perceptual model called APCA; it is not normative today.

The WCAG ratio is (L1 + 0.05) ÷ (L2 + 0.05), and L is relative luminance, not brightness. Green carries 71.52% of it and blue 7.22%, which is why pure blue on white passes at 8.59:1 while mid grey fails at 3.95:1. Here is the whole computation, run end to end.

The formula: two luminances and a constant

The whole of WCAG 2.x contrast is one line: ratio = (L1 + 0.05) ÷ (L2 + 0.05), where L1 is the relative luminance of the lighter of the two colours and L2 the relative luminance of the darker. Because the specification orders the two by luminance rather than by role, the ratio never depends on which colour you called foreground. Black text on white and white text on black are the same 21:1.

Everything interesting is hidden inside L. Relative luminance is a physical quantity: it estimates how much light the surface emits, normalised so that pure white is 1 and pure black is 0. It is deliberately not a measure of how bright the colour looks to a human eye, and it is definitely not the L in HSL or the L in CIELAB, both of which are perceptual and both of which give very different numbers for the same hex code.

That single distinction accounts for almost every argument a designer has with a contrast checker. The checker is not judging the design; it is dividing two numbers from a colorimetric model that was standardised for cathode-ray tubes and never claimed to match perception.

Step one: linearising each channel

An sRGB value of 128 does not carry half the light of 255. Display encodings are gamma-companded so that the limited 8-bit range is spent where human vision is sensitive, which means the stored number is a nonlinear function of the light. Before anything can be weighted or added, each channel has to be undone back to a linear quantity.

WCAG spells the transfer function out. Divide the 8-bit value by 255 to get c in [0, 1]. If c is at most 0.03928, the linear value is c ÷ 12.92. Otherwise it is ((c + 0.055) ÷ 1.055) raised to the power 2.4. The small linear segment near black exists because a pure power function has an infinite slope at zero, which is numerically awkward and physically wrong for a real display, so sRGB splices a straight line in underneath.

Run it and the nonlinearity is obvious: channel 128 linearises to 0.215861, not 0.5. Channel 10 gives 0.003035, channel 11 gives 0.003347. There is a footnote here worth knowing, because it comes up in bug reports. WCAG writes the threshold as 0.03928 while the IEC sRGB standard writes 0.04045. In 8-bit terms those cut at 10.0164 and 10.31 respectively, so both fall between integers 10 and 11 and neither ever selects a different branch. Sweeping all 16,777,216 sRGB colours against white produced zero verdict changes and a maximum ratio difference of exactly 0. The discrepancy is real in the text and has no effect on any 8-bit result.

Step two: the weights, where green wins

The three linear channels are combined as L = 0.2126 R + 0.7152 G + 0.0722 B. Those coefficients are the Rec. 709 luminance weights, and they sum to exactly 1, which is what makes white land on 1.000000. They encode a fact about the human retina rather than about the display: the cones that dominate luminance perception are far more sensitive to the middle of the visible spectrum than to its ends.

The consequences are stark once you compute the primaries. Pure red #ff0000 has relative luminance 0.212600. Pure green #00ff00 has 0.715200. Pure blue #0000ff has 0.072200. Green is nearly ten times the luminance of blue at identical channel intensity. Whether a colour passes a contrast check therefore depends far more on its green content than on anything a designer thinks of as its darkness.

A useful sanity check: find the grey with the same relative luminance as pure blue. It is #4c4c4c, at 0.072272 against blue's 0.072200. Both score 8.59:1 on white. To the model those two colours are interchangeable for contrast purposes, which they visibly are not to a reader — and that gap is exactly the criticism levelled at WCAG 2 by the people building its successor.

Pure blue passes, mid grey fails

Put the two through the formula. Pure blue on white: L1 = 1.000000, L2 = 0.072200, ratio = 1.05 ÷ 0.122200 = 8.59:1. That clears AA body text at 4.5:1 and AAA at 7:1. Mid grey #808080 on white: L2 = 0.215861, ratio = 1.05 ÷ 0.265861 = 3.95:1, which fails AA body text outright and only just clears the 3:1 large-text bar. Most people asked to guess would order those two the other way round.

The same weighting explains pure green. #00ff00 on white scores 1.37:1 — worse than almost any colour a designer would consider — because green is 71.52% of the luminance and full green is therefore nearly as bright as white. On black, the same green scores 15.30:1. Pure blue on black scores only 2.44:1 and fails even the 3:1 non-text threshold, which is the inverse of its excellent result on white.

For greys the boundary is worth memorising. Sweeping the neutral ramp against white, #767676 gives 4.5422:1 and passes AA, while the very next value #777777 gives 4.4781:1 and fails. #767676 is therefore the lightest grey that can carry body text on a white background, and #595959 lands on 7.00:1, the exact AAA line.

What the 0.05 is for

Black has relative luminance 0. Divide anything by 0 and the ratio is infinite, which is both useless as a score and wrong as a description of a real screen. The 0.05 added to both terms represents ambient light reflecting off the display surface: a black pixel is never truly black in a lit room, and the constant approximates the flare that lifts it.

The arithmetic is immediate. White on black becomes (1 + 0.05) ÷ (0 + 0.05) = 1.05 ÷ 0.05 = 21. That is where the famous 21:1 ceiling comes from — it is not a design decision, it is 1.05 divided by 0.05. Remove the constant and the same pair evaluates to Infinity, which is what a naive implementation returns and what several early contrast libraries actually shipped.

The constant also compresses the dark end of the scale. Two nearly black colours that differ by a factor of ten in luminance barely differ in contrast ratio, because 0.05 dominates both denominators. That is intentional — at those levels the difference genuinely is invisible under normal room light — but it explains why dark-mode palettes feel harder to tune: the formula stops discriminating exactly where dark themes live.

The thresholds, and what large text means numerically

Success criterion 1.4.3 (Contrast Minimum, level AA) requires 4.5:1 for text and images of text, dropping to 3:1 for large-scale text. Criterion 1.4.6 (Contrast Enhanced, level AAA) raises those to 7:1 and 4.5:1. Criterion 1.4.11 (Non-text Contrast, level AA) requires 3:1 for user interface components and graphical objects needed to understand the content — form borders, focus indicators, icon glyphs, chart segments.

Large scale is defined in points, not pixels: at least 18 point, or at least 14 point if bold. Converting at the CSS reference of 1pt = 4/3 px gives 24 px, and 18.67 px bold. Those are the numbers to check in a stylesheet. A 20 px heading is not large text by this definition and still needs 4.5:1, which catches a great many designs that assumed headings were exempt.

Two exemptions are worth knowing because they are frequently misread. Text that is part of an inactive user interface component, pure decoration, or invisible to everyone has no contrast requirement. And logotypes — text that is part of a logo or brand name — are exempt too. Neither exemption covers a low-contrast placeholder in a form field or a greyed-out label that users are still expected to read.

Where APCA and WCAG 3 fit

The grey-and-blue result above is not a bug in anyone's implementation; it is a limitation of the model. A luminance ratio does not know about hue, saturation, font weight, stroke width, or which colour is on top of which — all of which measurably affect legibility. A thin 400-weight face at 4.5:1 can be harder to read than a heavy face at 4:1, and the formula cannot say so.

APCA, the Accessible Perceptual Contrast Algorithm, is the response. It models perceived lightness rather than physical luminance, is deliberately polarity-aware — dark-on-light and light-on-dark give different scores for the same pair — and outputs a signed value on a roughly minus 108 to plus 106 scale rather than a ratio, with the target depending on font size and weight together.

Its status needs stating carefully. WCAG 3 is a W3C Working Draft — an early, explicitly unstable document that the W3C itself warns should not be used as a basis for implementation. APCA appears in that work as a candidate method, not a ratified requirement. Nothing anywhere is legally or contractually assessed against APCA today. WCAG 2.2 is the current W3C Recommendation and is what accessibility policy, procurement and litigation actually reference. Use APCA as a second opinion when the ratio and your eyes disagree; ship against the 2.x numbers.

L darker
Real pairs computed with the WCAG 2.x definition: relative luminances, the resulting ratio, and the verdict at each threshold
PairL lighterL darkerRatioAA body 4.5:1AA large 3:1AAA body 7:1
#000000 on #ffffff1.0000000.00000021.00:1PassPassPass
#0000ff on #ffffff1.0000000.0722008.59:1PassPassPass
#595959 on #ffffff1.0000000.0998997.00:1PassPassPass (exactly on the line)
#767676 on #ffffff1.0000000.1811644.54:1Pass (lightest grey that does)PassFail
#777777 on #ffffff1.0000000.1844754.48:1Fail (one step darker passes)PassFail
#ff0000 on #ffffff1.0000000.2126004.00:1FailPassFail
#808080 on #ffffff1.0000000.2158613.95:1FailPassFail
#00ff00 on #ffffff1.0000000.7152001.37:1FailFailFail
Color Contrast CheckerWCAG contrast ratio between text and background, with AA/AAA pass-fail and a live preview.Try the tool

Frequently asked questions

Why does pure blue on white pass while mid grey on white fails?
Because the formula weights green at 0.7152 and blue at only 0.0722. Pure blue has no red and no green at all, so its relative luminance is 0.072200 — very dark by the model's reckoning, even though it looks vivid. Mid grey #808080 has equal amounts of all three channels, and after linearisation its luminance is 0.215861, three times higher. Against white that gives 8.59:1 for blue and 3.95:1 for grey, so blue clears AA and AAA while grey fails AA body text. Nothing has gone wrong: relative luminance measures light output, not how colourful or how dark something looks. The practical lesson is to stop guessing. Saturated blues, purples and deep reds routinely score better than their appearance suggests, and yellows, cyans and light greens routinely score far worse. If you have a strongly coloured brand palette, measure every pair rather than eyeballing it, because intuition about which colours are dark is trained on perceived lightness and the formula is not.
Does the ratio change if I swap the foreground and background colours?
No. WCAG 2.x defines L1 as the relative luminance of the lighter colour and L2 as that of the darker, so the ordering is decided by the luminance values themselves, not by which one you painted the text with. Black text on white and white text on black both evaluate to 21:1. Blue #0000ff text on white and white text on blue both evaluate to 8.59:1. This symmetry is a real property of the 2.x model and a real limitation of it, because polarity does affect legibility: light text on a dark background tends to look thinner and can bloom on some displays, an effect the ratio cannot represent. APCA, the candidate algorithm in the WCAG 3 draft, deliberately breaks that symmetry and reports different values for the two orderings. Under WCAG 2.x, though, a contrast checker that gives you a different answer when you swap the fields has a bug.
What is the lightest grey I can use for body text on white?
#767676. Sweeping the neutral ramp against #ffffff, that value produces 4.5422:1 and clears the AA threshold of 4.5:1. The next step up, #777777, produces 4.4781:1 and fails. There is no grey between them — those are adjacent 8-bit values — so #767676 is exactly the boundary. Two practical consequences. First, if your design system uses a token like grey-500 for secondary text, check its actual hex: a great many systems land in the 0x77 to 0x99 range and are non-compliant for body copy while looking perfectly deliberate. Second, if you need AAA, the equivalent boundary is #595959, which computes to 7.00:1 exactly. Both figures assume a pure white background; on an off-white surface such as #fafafa the whole scale shifts and you have to recompute. That is what the contrast checker is for — the boundary moves with the background, and memorised values stop being true the moment the background does.
Do icons, form borders and focus rings need 4.5:1 too?
No — they fall under success criterion 1.4.11, Non-text Contrast, which asks for 3:1 at level AA. It applies to two things: the visual information needed to identify a user interface component and its state, and graphical objects required to understand the content. So the border that tells you where a text input begins needs 3:1 against its surroundings, as does a focus indicator, a checked checkbox, a toggle in its on state, and the differently coloured segments of a chart that the reader must distinguish. What it does not cover is decoration, a component's inactive state, or graphics where a specific presentation is essential — a photograph, a flag, a screenshot of another product. Note the comparison is against adjacent colours, which for a focus ring means the background it sits on, and for an icon on a coloured button means the button fill, not the page. This one criterion catches more real defects in modern component libraries than the text rules do, because form fields drawn with a 1px light grey border are close to universal.
Is APCA replacing the 4.5:1 rule, and should I switch now?
Not yet, and possibly not in that form. WCAG 3 is a W3C Working Draft, a status that explicitly means unstable and not suitable as an implementation basis; its own text warns readers not to cite it as anything settled. APCA appears there as a candidate method under evaluation, alongside open questions about thresholds and about how conformance would be scored at all. Meanwhile WCAG 2.2 is a W3C Recommendation, and it is WCAG 2.x that accessibility legislation, public procurement rules and legal complaints around the world point at. Nothing is assessed against APCA today. The sensible position is to keep conforming to 2.x — 4.5:1, 3:1, 7:1, computed as described above — and to treat APCA as a diagnostic when the ratio and your eyes disagree, which happens most often with saturated hues and with thin type on dark backgrounds. If APCA says a pair is worse than the ratio suggests, that is worth acting on; if it says a pair is better, you still cannot ship below 4.5:1 and claim AA.
How do I compute the ratio by hand for a specific colour?
Take #1a73e8 on white as a worked example. Split the hex into channels: 26, 115, 232. Divide each by 255: 0.101961, 0.450980, 0.909804. All three exceed 0.03928, so all three use the power branch: ((c + 0.055) ÷ 1.055) to the 2.4, giving 0.010330, 0.171441, 0.806952. Apply the weights: 0.2126 × 0.010330 = 0.002196, 0.7152 × 0.171441 = 0.122615, 0.0722 × 0.806952 = 0.058262. Sum them for L = 0.183073. White is 1.000000 and is the lighter, so the ratio is (1 + 0.05) ÷ (0.183073 + 0.05) = 1.05 ÷ 0.233073 = 4.51:1. That passes AA body text by six hundredths, which is worth noticing: a colour that close to the line will fail the moment anyone nudges it lighter, so treat 4.5 to 4.6 as a warning band rather than a pass. The whole procedure is twelve lines of code in any language, which is why every contrast checker agrees to the second decimal — the only real source of disagreement between tools is rounding.

Articles you may find interesting

All guides
ExplainerGradients, Banding, and Why the Middle Looks MuddyInterpolating in sRGB averages gamma-encoded numbers, so the midpoint of red to green is #808000 when the half-light answer is #bcbc00 — 57.2% too little light. Banding is a separate arithmetic problem: 8 bits give 256 steps, and a dark gradient may have only 28 of them. Here is both, computed.ExplainerColour Schemes Are Geometry on a Wheel — and the Wheel Is WrongComplementary, triadic, analogous and split-complementary are just rotations: add 180°, 120°, 30° or 150° to a hue. The arithmetic is trivial. The problem is that the HSL hue circle is not perceptually uniform — yellow and blue at the same HSL lightness differ in luminance by 12.85 times — so a generated palette has to be contrast-checked afterwards.How-toHEX vs RGB: How to Read and Convert ColoursHEX and RGB are two notations for the same colours. Here's how to read each, convert between them, and add transparency.GuideWhat a Minifier Can Remove, and What It Must NotMinification has to preserve meaning, and the interesting part is the whitespace that carries meaning: the descendant combinator, the spaces inside calc(), the gap between two inline elements. Measured here on real files, including how much brotli was going to save you anyway.ExplainerHow Instagram Fonts Actually Work (They Are Not Fonts)Nothing is styled. Each letter is swapped for a different Unicode character that happens to look bold or cursive — which is why a screen reader reads the result as gibberish and some devices show empty boxes.ExplainerWhat an Availability Percentage Actually AllowsThree nines sounds like a promise until you divide it into minutes. What 99.9 % buys per year, per month, per week and per day; why the measurement window matters far more than the extra nine; and the two different months this tool uses for the same slug.

Related tools

Sources

Spotted a mistake in this article?