Audit Contrast and Focus Paths Before You Ship
Measure contrast by number, run axe, and walk the keyboard focus path with a script to catch what the tool cannot see.
A tool can measure contrast. It cannot tell you whether you can see where the keyboard focus is. Do both in a few minutes, in this order.
Step 1: contrast, by number
WCAG AA asks for 4.5:1 for normal text, 3:1 for large text, and 3:1 for parts of a control such as borders and focus rings. This function applies the WCAG formula:
def luminance(hex_color):
r, g, b = (int(hex_color.lstrip("#")[i:i + 2], 16) / 255 for i in (0, 2, 4))
f = lambda c: c / 12.92 if c <= 0.03928 else pow((c + 0.055) / 1.055, 2.4)
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
def contrast(a, b):
hi, lo = sorted((luminance(a), luminance(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
for fg, bg in [("#9aa0a6", "#ffffff"), ("#626976", "#ffffff"), ("#034bff", "#ffffff"), ("#c9ced6", "#ffffff")]:
print(fg, "on", bg, f"{contrast(fg, bg):.2f}")
#9aa0a6 on #ffffff 2.64
#626976 on #ffffff 5.52
#034bff on #ffffff 6.08
#c9ced6 on #ffffff 1.58
The light grey text fails at 2.64. #626976 passes at 5.52. The last pair is an input border: 1.58 is too faint to be the only thing that marks a field.
Step 2: run axe
I ran axe-core 4.14.0 in Chrome 154 against a contact page with a grey intro line, an input, a button and a link:
axe violations: ['color-contrast', 'landmark-one-main', 'region']
The axe message for contrast gives the same ratio: 2.64 against the 4.5:1 target. Note what is missing. Nothing in that list mentions focus.
Step 3: walk the focus path with a script
WALK = """() => { const e = document.activeElement; const s = getComputedStyle(e);
return { tag: e.tagName.toLowerCase(), text: (e.innerText || e.getAttribute('placeholder') || '').trim().slice(0, 20),
outline: s.outlineStyle === 'none' ? 'none' : s.outlineWidth + ' ' + s.outlineStyle,
shadow: s.boxShadow === 'none' ? 'none' : 'yes' } }"""
for i in range(3):
page.keyboard.press("Tab")
print(page.evaluate(WALK))
On the page as I found it, with a:focus, .btn:focus { outline: none; }:
Tab 1: <input> 'Email address' outline: 1px auto shadow: none
Tab 2: <button> 'Send' outline: none shadow: none
Tab 3: <a> 'Help centre' outline: none shadow: none
The input keeps the browser's default ring. The button and the link have no outline and no shadow, so a keyboard user cannot see where they are.
Step 4: fix and run both checks again
.muted { color: #626976; }
a:focus-visible, .btn:focus-visible, input:focus-visible {
outline: 3px solid #034bff;
outline-offset: 2px;
}
I also wrapped the content in <main>. Re-running:
Tab 1: <input> 'Email address' outline: 3px solid shadow: none
Tab 2: <button> 'Send' outline: 3px solid shadow: none
Tab 3: <a> 'Help centre' outline: 3px solid shadow: none
axe violations: []
The ring colour #034bff on white is 6.08:1, so it passes the 3:1 requirement for non-text parts.
⚠ Warning: A script can say an outline exists. It cannot say whether the ring is visible against the background around it. Look at it on every background colour the control sits on.
Common mistakes
- Using
:focus-visibleon some controls and removing the outline from others. - Checking contrast only for the default state and not for hover, disabled and error.
- Treating an empty axe report as a pass.
Related course: HTML and CSS Advanced.
About
Measure contrast by number, run axe, and walk the keyboard focus path with a script to catch what the tool cannot see.
- Responsible
- Mary Q
- Last Update
- 08/15/2026
- Reading Time
- 3 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.