Contrast Checker - Two Colours, the Exact Ratio, and Every Rounding That Gets You There
Paste a foreground and a background as hex, rgb(), hsl(), hwb(), a CSS name or a wide-gamut function and read the WCAG 2.1 and 2.2 verdicts with the arithmetic shown: the 8-bit channels computed three ways, the layer stack a translucent colour really paints, the smallest lightness or opacity move that reaches the line with the steps counted, a palette matrix over every pair you paste, and a differential run against your own browser's colour parser, canvas and computed styles at the bottom. Nothing you type leaves this device.
Painted by your browser, at the size you picked
How each string you wrote was read
| Which box, and the string in it | Read as | Channels before rounding | The 8-bit answer | Notes |
|---|
The same colours computed three ways, and painted by your browser
| Which answer | Exact fractions | The specification's own algorithm, in doubles | Your browser | Where they part company |
|---|
The stack, top to bottom, and what each layer leaves
| Layer | Its own 8 bits | Alpha | What the reader sees | How |
|---|
The smallest move along a slider that reaches the line
| The threshold | How far it is | Move to try | The colour it gives | The ratio there | Steps |
|---|
Every ordered pair in that palette, worst first
| Pair | On top | Underneath | Ratio | Body text | Large text |
|---|
Where reading stopped, and why
| The thing it stopped on | What it says instead | Code |
|---|
Declined to answer
| The thing it stopped on | Why, and what it does instead |
|---|
Against your own browser
| The rule | What the arithmetic made | What your browser made |
|---|
The two arithmetics behind the ratio
What the number is, and the two arithmetics that get you there
A contrast ratio is one division between two luminances, and each luminance is three bytes pushed
through a power curve. That is the whole subject. WCAG writes it as (L1 + 0.05) / (L2 + 0.05) with
the lighter value on top, where L is 0.2126 R + 0.7152 G + 0.0722 B over linearised channels: a
byte is divided by 255, and if the result is on the dark side of a knee it is divided by 12.92, otherwise
it goes through ((c + 0.055) / 1.055) ^ 2.4. Two floors of + 0.05 keep black-on-white finite and stop
a pale background from scoring absurdly. The ceiling of the scale is 21 to 1, which is why every tool
that reports 25 to 1 has a bug in it.
Everything arguable about a checker lives one step earlier, in the words “a byte”. Your colour did not
arrive as three bytes. It arrived as hsl(14 50% 50%), or rgb(50% 50% 50%), or tomato, and
somebody had to divide, multiply, interpolate a hexagon and round before a byte exists. The arithmetic keeps
those fractions exact - as whole-number ratios, not as floating-point - and rounds exactly once, at the
last step, away from zero. It then prints the answer beside two other answers, because one of them is
the arithmetic in the specification’s own printable appendix and the third belongs to your browser.
Three answers for one colour, all three printed
The byte table has four columns of numbers for each colour you wrote. The first is the arithmetic’s, computed in fractions. The second runs the same conversion through ordinary 64-bit doubles using the algorithm the CSS Color specification prints in its appendix, which is what most code on the internet is doing. The third is your browser: your string is painted into a one-pixel canvas and the bytes read back out of the framebuffer, so that column is not a model of any browser, it is the answer from the one you are using.
They agree most of the time. Here is a case where they do not, and it is worth working through because it
is the whole lesson: hsl(0 80% 50%). The arithmetic’s fractions say 230, 26, 26. The appendix algorithm in
doubles says 230, 25, 25. The cause is a value landing exactly on a half. That colour’s mid channel
comes to 25.5, and 25.5 is not a number a double can hold - so the double path sees something a hair
either side of it, and this time it sees 25.49999999999999993, because along the way it computed
1 - 0.9 and got 0.09999999999999998. The exact path sees 25.5 and rounds away from zero, to 26.
Neither is a mistake. One is a lost fraction and the other is a tie broken on purpose.
Over a grid of 52,920 hsl() colours - every third hue, saturation and lightness in steps of five -
3,960 of them have a channel sitting exactly on a half, and the two routes print different bytes for 970
of them: about 1.8 per cent, or one in four of the ties. Two things about that count are the point. No
disagreement happens at all unless a channel is exactly on a half: the implication was checked in
both directions and the exceptions number zero. And every disagreement rounds the same way - the double
route loses a fraction, so it lands below the exact answer and never above it. If a checker’s hsl()
bytes are one off from another’s, this is what happened to it, and it is not evidence that one of them is
broken. So no winner is picked: all three are printed, with the cells that differ and why.
Your own browser’s column is the one that decides what a visitor to your site actually sees.
A translucent colour is not the colour your reader sees
rgba(255, 255, 255, 0.5) on #666666 is not white on grey. It is light grey on grey: white at a half
painted over 102 lands on 179,179,179, and the pair that reaches the eye is 179 against 102, which is
2.74 to 1. Measure the typed white against that background instead and you get 5.74 to 1 - a pass, and
wrong by three points. Plenty of checkers make exactly this mistake, because computing it correctly means
committing to what is behind the background.
So there is a third box: what is behind that, if it is see-through. Give it #ffffff and the
background flattens to 179,179,179 and black text on it scores 10.02 to 1. Leave it empty and nothing is
invented: the see-through background is treated as sitting on top of itself, which is
the one assumption that changes no arithmetic, because a colour composited over the same colour is that
colour - and it says so in the table instead of hiding it. If your background is fully opaque, anything
you type behind it is dropped with a line explaining why, because there is no hole for it to show through.
Translucency also moves the fix. rgba(128, 128, 128, 0.5) on white fails 4.5 to 1, and there is no
lightness of that grey that passes while it stays half-transparent: at a half, the composite can only ever
be between white and 128, which tops out under the line. What passes is 0.464 lightness at full opacity
- 538 steps along the two sliders together, landing on #767676. Lightness alone, opacity alone, and both are searched, each costed in steps, the cheapest offered and the runners-up listed. That is why the fix table can honestly say “more opaque” as well as “darker”.
And the canvas has one more round of quantisation in it. A real canvas stores its pixels already multiplied
by their alpha, then divides them back out when asked. Paint rgb(200 120 40 / 0.31) on an empty one-pixel
canvas and ask for the bytes: this machine’s Chrome answers 200, 119, 39 - two channels one low, with
nothing on a half to argue about, because the alpha itself became 79 of 255 before the multiply. That
drift is not noise, it is a model, and the composite row names which 8-bit model your browser used - the
premultiplied one a canvas has, or the naive one-rounding model most code reaches for - or says plainly
that it is neither of them. Over #123456 above this machine’s Chrome painted 74, 72, 71, where the
arithmetic’s fractions give 74, 73, 72 and the premultiplied model gives 74, 73, 71: one green step below even
the model a canvas is supposed to follow, which is why the row prints the buffer’s answer and refuses to
name a winner among the three.
The size question is settled by your style engine, not by a factor
Large text is 3 to 1 rather than 4.5 to 1, and “large” means 24px, or 56/3px (18.67px) at weight 700 or
heavier. Two of those numbers are not integers and one of them is normally written as a different unit
entirely: 18pt. pt is converted to px at 96 over 72, exactly as CSS defines the inch, and rem at
your root font size - but the conversion is treated as a claim to be checked, so your browser is asked
what 18pt computed to and both answers go in the differential table. If they disagree - a root
size you have changed, a unit there is no conversion for - the row says so and the browser’s own
number is used, because the browser is the thing that will lay out your page.
Weight matters only at the 18.67px boundary, and only for 700 and above: 19px at weight 600 is body text,
24px at weight 400 is large. The grey from the very first section is a good illustration of the whole
size rule: #777777 on white is 4.48 to 1, which fails body text by 0.02 and passes large text with a
point to spare, so the same pair is a defect and a pass depending on nothing but a stylesheet line.
Where a fix comes from, and where it honestly does not
The famous answer to “what is the lightest grey that passes on white” is #767676, and it is arrived at by walking: 119 down to 118 is three steps of the lightness scale from #777777, and 118 gives 4.542 to 1. The last column of that table is the step count, and it is the part a reader can verify by hand. The row under it - “the lightness this verdict survives” - is the walk in the other direction: from 0.465 to 1.000, every lightness that keeps the same pass or fail, so you know how far you may move before the verdict flips.
Some pairs cannot be fixed by moving the foreground at all. Black on #7f7f7f is 5.24 to 1: it clears 4.5, and 7 to 1 is out of reach because that background is too bright for black and too dark for white, and no lightness or opacity of the text changes that. The ceiling is printed - the best either slider can do, at your alpha and at full opacity - and the only true thing left is said, which is that the background has to move. It is not dressed up as a fix. (4.5 to 1 itself is unreachable in this way for a reason worth knowing: on any 8-bit background, either pure white or pure black clears it, so a fail there always has a way out.)
The palette box runs the same composite over every ordered pair you paste - up to twelve colours, 132 pairs, worst first - and each pair’s top colour is painted over the one beneath it before it is measured, so a translucent swatch is measured as what it shows rather than as what was typed.
The knee argument, which cannot matter at eight bits
Some documents put the linearisation knee at 0.03928, some at 0.04045; the difference is a
back-patch to the sRGB standard that WCAG did not follow. It is a real disagreement, and at eight bits it
is an unobservable one. The two knees sit, in byte terms, between 10.0164 and 10.31475 - a window 0.3 of a
byte wide. All 256 channel values were enumerated looking for one inside it and none was found. So the
formula branch taken by byte 10 and byte 11 is the same whichever published number you believe, and the
ratio you read here is not sensitive to that argument. It reports both knees’ band rather
than asking you to trust the check.
Out of scope, permanently
The last table is not a failure list, it is a scope statement, and it is there whether or not you typed
anything. It declines APCA, whose Lc is a lookup through tuned tables and is not a WCAG verdict. It
declines to speak for your monitor, because the operating system, the display profile and any night-mode
filter sit between these bytes and your screen. It declines the glyph: stroke weight, hinting and font
choice change readability at a ratio that is a statement about two colours and nothing else. It declines
gradients and photographs behind text, because one ratio needs two flat colours. It declines
contrast-color() even where your browser can parse it - this machine’s Chrome answers yes when asked
whether the keyword is supported, and the differential prints that answer in its own row - because a
checker that picks the foreground for you hides which foreground it picked, and the ratio you asked about is exactly
what gets printed. And it declines to print a name table: it asks your browser what tomato is
and prints the answer, which also means a wide-gamut color(display-p3 1 0 0) is measured rather than
guessed - on the machine that will show it, in the space it is drawing into. On this machine that answer
is 255, 0, 0: a plain 8-bit canvas here clamps the components into the canvas’s own space instead of
converting them, so p3 red lands on sRGB red, and the ratio printed beside it is the ratio of the bytes
the buffer actually holds rather than the one a colour-management model would promise.
All of it runs in your browser, in one file, on the two strings you typed. Nothing you type leaves this device: nothing is sent off to be scored, and the report is built from numbers already on the screen so you can paste it into a ticket without checking whether a copy was kept.
Reading the differential at the bottom
The table is the same question asked twice - once in exact fractions, once by your browser - with a verdict column. Three words appear there. same means both implementations printed identical bytes. different means they did not, and the row’s last column says why: a channel on a half, a canvas buffer holding its colour premultiplied, a colour space that will not be converted by itself. not checked means your browser offered nothing to compare against - no canvas, no computed style, a parser that declined the string - and an empty comparison is printed rather than a reassuring one.
Two behaviours there are worth knowing, because both were measured rather than assumed. Assigning a
colour your style engine rejects does not clear the property; it leaves the previous value in place
looking like an answer, so every probe here is loaded with a sentinel first and a read-back that equals
the sentinel is treated as a refusal. And a detached element computes to nothing, so the size probe is
attached, read and only then taken down. The rows about CSS.supports are not scoring your browser
either: they record which of five colour syntaxes it parses, and the arithmetic’s own answer for each is
printed beside it - which is how you can see that nothing will be interpolated, no colour picked for you,
and the hwb() arithmetic done exactly.