How to check visible focus indicator
Reach each control with the Tab key, then read the computed outline-style and box-shadow of document.activeElement. A control with outline-style: none and no shadow has no indicator. Reading outline-width instead is the trap: it comes back as 3px on a control whose ring was removed.
Why check this
Run this whenever a design system ships new styles, and on any screen where a stylesheet reset has touched outline. The failure it prevents is quiet: a keyboard user tabs into the payment form, the ring is gone, and the page gives no sign of where the next Enter will land. Nothing throws, nothing looks broken on a mouse pass, and the control still works, which is why the defect survives release after release.
The check reads what Chrome computed for the focused element, so it answers reachability and styling in one pass. It does not measure the ring against the background. That comparison needs the colour of the ring and the colour of the pixels underneath it, which is what the contrast checker above takes.
Prerequisites
- Node 22 and Chrome on one machine, plus
npm i puppeteer-core. Thechannel: 'chrome'option uses the browser that is already installed. See the puppeteer API. - The demo server from how to test keyboard navigation on a website. Two of its buttons are deliberately styled:
#savehas its outline removed and nothing put back,#fancyhas its outline removed and abox-shadowring drawn instead. Save it asfocus-demo.mjs.
// focus-demo.mjs - the two buttons this check is about
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Focus demo</title><style>
body{font:16px system-ui;margin:2rem;max-width:34rem}
.row{display:flex;gap:.5rem}
#back{order:1} #cancel{order:2} #next{order:3} /* visual order, not DOM order */
#save:focus{outline:none} /* indicator removed */
#fancy:focus{outline:none;box-shadow:0 0 0 3px #0f766e} /* ring drawn by box-shadow */
.fakebtn{display:inline-block;background:#eee;border:1px solid #999;padding:.2rem .6rem}
</style></head><body>
<a href="#main">Skip to content</a>
<h1 id="main">Checkout</h1>
<label>Coupon <input id="coupon"></label>
<div class="fakebtn" id="apply" onclick="alert('applied')">Apply</div>
<div class="row"><button id="back">Back</button><button id="next">Continue</button><button id="cancel">Cancel</button></div>
<button id="save">Save card</button>
<button id="fancy">Fancy</button>
<label><input type="checkbox" id="news" tabindex="1"> Email me offers</label>
<label>Card <input id="card"></label>
<script>
// The card field swallows Tab, the way a hand-rolled autocomplete often does.
document.getElementById('card').addEventListener('keydown', e => { if (e.key === 'Tab') e.preventDefault(); });
</script></body></html>`;
createServer((_, res) => res.end(page)).listen(8794, () => console.log('http://localhost:8794/'));
- Every figure below is one capture on one machine, Chrome 152.0.7977.76. The default ring differs between browsers and between Chrome versions.
- WCAG 2.4.7 Focus Visible is Level AA and asks only that an indicator is visible. WCAG 2.4.13 Focus Appearance is Level AAA and puts numbers on it.
Steps
- Step 1.
Start the demo server in its own terminal.
node focus-demo.mjshttp://localhost:8794/ - Step 2.
Tab through the page and print the three properties that decide whether an indicator exists.
// focus-style.mjs - node focus-style.mjs <url> <presses> import { launch } from 'puppeteer-core'; const [url, n = '9'] = process.argv.slice(2); const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); for (let i = 1; i <= Number(n); i++) { await page.keyboard.press('Tab'); console.log(i + '. ' + await page.evaluate(() => { const e = document.activeElement, c = getComputedStyle(e); return `${e.tagName.toLowerCase()}#${e.id || '-'} focus-visible=${e.matches(':focus-visible')} ` + `outline-style=${c.outlineStyle} outline-width=${c.outlineWidth} box-shadow=${c.boxShadow}`; })); } await browser.close();node focus-style.mjs http://localhost:8794/ 91. input#news focus-visible=true outline-style=auto outline-width=1px box-shadow=none … 6. button#cancel focus-visible=true outline-style=auto outline-width=1px box-shadow=none 7. button#save focus-visible=true outline-style=none outline-width=3px box-shadow=none 8. button#fancy focus-visible=true outline-style=none outline-width=3px box-shadow=rgb(15, 118, 110) 0px 0px 0px 3px 9. input#card focus-visible=true outline-style=auto outline-width=1px box-shadow=noneStops 7 and 8 both report
outline-width: 3pxwith no outline drawn.outline: nonesets the style tononeand leaves the width at its initialmedium, which computes to 3px. - Step 3.
Focus one healthy button twice, once with the mouse and once with Tab, and compare.
// mouse-vs-tab.mjs - node mouse-vs-tab.mjs <url> <selector> <presses-to-reach-it> import { launch } from 'puppeteer-core'; const [url, sel, n = '4'] = process.argv.slice(2); const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); const read = () => page.evaluate(() => { const e = document.activeElement, c = getComputedStyle(e); return `focus=${e.id} :focus-visible=${e.matches(':focus-visible')} outline-style=${c.outlineStyle} outline-width=${c.outlineWidth} outline-color=${c.outlineColor}`; }); await page.goto(url, { waitUntil: 'networkidle2' }); await page.click(sel); console.log('mouse click : ' + await read()); await page.reload({ waitUntil: 'networkidle2' }); for (let i = 0; i < Number(n); i++) await page.keyboard.press('Tab'); console.log('Tab key : ' + await read()); await browser.close();node mouse-vs-tab.mjs http://localhost:8794/ "#back" 4mouse click : focus=back :focus-visible=false outline-style=none outline-width=3px outline-color=rgb(0, 0, 0) Tab key : focus=back :focus-visible=true outline-style=auto outline-width=1px outline-color=rgb(16, 16, 16)One button, two readings. The mouse line carries the same three outline values as the broken
#saveline in step 2. - Step 4.
Name the rule that removed the ring, so the ticket points at a line instead of a symptom.
// outline-rules.mjs - node outline-rules.mjs <url> import { launch } from 'puppeteer-core'; const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); await page.goto(process.argv[2], { waitUntil: 'networkidle2' }); console.log(await page.evaluate(() => { let read = 0, blocked = 0; const rules = [...document.styleSheets].flatMap((s) => { try { const r = [...s.cssRules]; read++; return r; } catch { blocked++; return []; } }); const hits = rules.filter((r) => /outline\s*:\s*(none|0)\b/.test(r.cssText)).map((r) => ' ' + r.cssText); return `${read} stylesheet(s) read, ${blocked} blocked by CORS, ${rules.length} rules\n` + (hits.join('\n') || ' (no rule removes the outline)'); })); await browser.close();node outline-rules.mjs http://localhost:8794/1 stylesheet(s) read, 0 blocked by CORS, 8 rules #save:focus { outline: none; } #fancy:focus { outline: none; box-shadow: rgb(15, 118, 110) 0px 0px 0px 3px; } - Step 5.
Point the same script at a site that keeps its rings, and read the sheet counters.
node outline-rules.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP17 stylesheet(s) read, 0 blocked by CORS, 381 rules (no rule removes the outline)The counters are the point. A cross-origin stylesheet raises on
cssRulesand would be counted as blocked, so a clean result withblockedabove zero means the audit read less than the page uses.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| outline-style: auto | The browser default ring is drawn | Nothing. Check its contrast if the background is dark |
| outline-style: none, box-shadow: none | No indicator at all | Fix the rule step 4 named. This fails WCAG 2.4.7 |
| outline-style: none with a box-shadow | A custom ring replaces the default | Measure it. A 3px shadow clears the 2 CSS px rule |
| :focus-visible=false | Chrome does not treat this focus as keyboard focus | Reach the control with Tab and read it again |
| outline-width: 3px on its own | Nothing. The width is the initial value | Read outline-style before believing any width |
Common mistakes
Thresholds
The #fancy button draws 0 0 0 3px, a 3px ring on all four sides, which clears the size half of that number. Level AA, criterion 2.4.7, sets no number at all and asks only that a visible indicator exists.
What to check next
- How to test keyboard navigation on a website: reach the controls first, since an unreachable control has no indicator to read.
- How to check focus order: a visible ring that jumps around the layout is still a failure.
- How to check color contrast ratio: turns the
outline-colorvalue above into a pass or a fail against the 3:1 figure. - How to test a focus trap in a modal: dialogs are where custom rings and custom focus handling usually arrive together.
FAQ
What is a focus indicator?
The mark a browser draws around the control that will receive the next keystroke. In Chrome it is an outline with outline-style: auto, drawn by the user agent stylesheet on :focus-visible. Authors replace it with an outline of their own or with a box-shadow ring.
What is a visible focus indicator?
The term from WCAG 2.4.7, Level AA: any keyboard operable interface has a mode of operation where the keyboard focus indicator is visible. The criterion names no size and no colour. The numbers arrive at Level AAA with criterion 2.4.13.
Why does the ring appear when I press Tab but not when I click?
Chrome draws its default ring on :focus-visible, which matches keyboard focus and not a mouse click on a button. Step 3 shows both readings for the same control. Test with the Tab key, or the result describes the mouse rather than the page.
Is replacing the outline with a box-shadow a failure?
Not by itself. A shadow ring is visible and can be thicker than the default. It sits outside the element and can be clipped by an ancestor with overflow: hidden, so confirm the ring is drawn on the control in place, not only in the computed style.
Can I check this without a browser?
No. The rule is in the stylesheet, the ring is drawn by the browser, and :focus-visible depends on how focus arrived. A stylesheet search finds outline: none, which step 4 does more precisely, but it cannot tell you whether anything was put back.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: color-contrast contrast ratio of two colours against WCAG AA and AAA for normal and large text
- Accessibility testing checklist
- All accessibility checks
intermediate10 minpublished updated Maks Verny