Contrast is measured, and your borders have to pass it too
#767676 on white passes at 4.54:1 and #777777 fails at 4.47:1. Contrast as a number instead of a judgment, and the criterion covering borders and focus rings.
#767676 on white measures 4.54:1. #777777 on white measures 4.47:1. The
first one meets Level AA for body text and the second one doesn’t, and they are
one hex digit apart.
I can’t tell them apart by eye. That’s why the criterion is a number instead of a judgment, and it’s why I stopped fixing contrast by picking a darker gray and looking at the result.
Every pair of colors already has a number, and you look it up
WCAG scores a pair of colors as a contrast ratio. The scale starts at 1:1 for two identical colors and stops at 21:1 for black on white. You read the number off a checker, off the contrast panel in DevTools, or off the demo further down. There’s nothing to work out by hand.
Two properties of that number catch people out.
It doesn’t care which color is the text. #767676 on a white background and
white on a #767676 background both come back as 4.54:1. Hold on to that one.
It’s where this whole approach gets into trouble at the end of the post.
Hue moves it further than you’d guess. #ffff00 on white is 1.07:1 and there is
nothing you can do to save it. #0000ff on white is 8.59:1 and clears AAA.
A reading of exactly 3.00 is the one to distrust
#0099ff on white is 2.9998:1. Shown to two decimal places that reads 3.00,
which looks like a pass against the 3:1 threshold. It isn’t one. The criterion
says at least 3:1, and this is below it. #0080aa does the same thing at the
other threshold: it displays as 4.50 and sits under 4.5.
Whether you ever see this depends on whether your checker rounds the last digit or truncates it. The demo below truncates, so those two show as 2.99 and 4.49. Any tool that hands you exactly 3.00 or exactly 4.50 is worth a second look before you close the ticket.
“Large text” is a point size, and it’s smaller than it sounds
Past a size threshold the requirement drops from 4.5:1 to 3:1. WCAG writes that threshold in points:
at least 18 point or 14 point bold
CSS doesn’t work in points. Understanding 1.4.3 gives the conversion:
The ratio between sizes in points and CSS pixels is
1pt = 1.333px, therefore14ptand18ptare equivalent to approximately18.5pxand24px.
So 24px at any weight gets the lower threshold, and 18.5px gets it only when
it’s bold. 18.5px at font-weight: 400 needs the full 4.5:1. Changing the
weight on a heading can move it across that line without changing its size. Swap
one weight token for another and the threshold underneath it changes with it.
Move the pickers and watch the verdict flip
Try it
Change either color and watch the ratio. The presets are the pairs named in this post. The ratio is truncated rather than rounded, so a reading of 4.49 is a pair that genuinely missed 4.5. Paste the same values into the WebAIM checker and a rounded reading can differ in the last digit.
Or start from one of these
The lightest gray that clears 4.5:1 on white.
Normal text, the size this post is set in.
Large text, 24px and up.
A bordered control
- Measured
- 4.54:1
- AA normal text needs 4.5:1
- Pass
- AA large text needs 3:1
- Pass
- AA components needs 3:1
- Pass
- AAA normal text needs 7:1
- Fail
- AAA large text needs 4.5:1
- Pass
Non-text contrast, the one covering components, exists only at AA. There is no AAA version of it.
1.4.11 covers the border, not the label inside it
Everything up to here is SC 1.4.3, Level AA, and it only applies to text. SC 1.4.11 Non-text Contrast arrived in WCAG 2.1 and asks for 3:1 on two other things:
- visual information required to identify user interface components and their states
- parts of graphics required to understand the content
Components first: the input border, the checkbox outline, the glyph in an icon-only button, the mark showing which tab is selected. Graphics is the narrower one. Think of a pie chart whose labels sit outside the slices, where the boundary between two slices is the only thing telling them apart.
Focus rings are in there too. Understanding 1.4.11 says so directly:
In combination with 2.4.7: Focus Visible, the visual focus indicator for a component must have sufficient contrast against the adjacent background when the component is focused.
Two exceptions save you real work.
An inactive component is exempt. The pale gray border on a disabled input has no ratio to meet, and that’s stated rather than implied.
A control with visible text in it needs no contrasting boundary at all:
If a control has visible content (such as text or a sufficiently contrasting icon), which helps users identify the presence of the control, then a border or other indication of the overall boundary of the hit area is not required.
A button with the word Save in it, at 4.5:1 against its own background, has already told you it’s a button. The 1px line around it is decoration and the criterion leaves it alone. An icon-only button is the opposite case: the glyph is the only thing identifying the control, so the glyph is what has to clear 3:1.
One more piece of wording that changes what you measure. “Adjacent colors” means what sits outside the component:
if an input has a white internal background, dark border, and white external background the “adjacent color” to the component would be the white external background.
And there is no AAA version of this. SC 1.4.6 raises text to 7:1 and 4.5:1; non-text contrast stays at 3:1 at every level.
When I audited an app I’d been working on, the text failures were the
straightforward half. A light gray label is visible as a light gray label. I
could measure it, darken it, and move on. The borders and focus rings were the
ones I would have walked past, because #d1d1d1 against white measures 1.52:1
and still looks like a border. It looks like every border I’ve ever drawn. I
found those by measuring them, and the rest of that audit is in
what it took to retrofit accessibility onto a shipped app.
4.5:1 doesn’t mean readable, and you ship to it anyway
Back to the first of those two properties. Light text on a dark background and dark text on a light one score identically, and they do not read identically.
APCA, the candidate replacement being developed for WCAG 3, is blunt about this. Its own documentation says the 2.x math “far overstates contrast for dark colors to the point that 4.5:1 can be functionally unreadable when a color is near black”, and that “WCAG 2.x contrast cannot be used for guidance designing ‘dark mode’”.
I think that criticism is correct, and it changes nothing about what I do. APCA isn’t normative. WCAG 3 isn’t a Recommendation. The audit tests 2.x, the VPAT records 2.x, and a border that clears 3:1 is a pass whatever APCA thinks of it.
So measure to 2.x, because that’s what conformance is scored against. Then open the dark theme and look at it. If pure white on near-black is glaring, pull the white down anyway. You’ll lose ratio and gain readability, and the score will go on telling you the version you rejected was the better one.