How to test keyboard navigation on a website
Press Tab from the top of the page and write down where focus lands at each stop. Every control that answers a mouse click has to appear in that list, and Tab has to keep moving. The script below prints one line per stop, so a control the keyboard cannot reach shows up as a missing line.
Why check this
Run this on every screen that takes input, before release sign-off, and again after a component library upgrade changes what a button is made of. Mouse testing never exercises the keyboard path, so the defects survive a full manual pass. Two of them are expensive. A coupon field whose Apply control is a div cannot be used without a mouse, so the order goes through at full price. A field that swallows Tab locks the whole page for anyone who is not holding a mouse, because there is no way back out.
The check answers one question: which controls are in the tab sequence and in what order. It does not tell you what a screen reader announces, whether the name on a control is useful, or whether the focus ring has enough contrast. Those are separate passes, and the pages linked at the end cover them.
Prerequisites
- Node 22 and Chrome on the same machine.
npm i puppeteer-coregives you the driver, andchannel: 'chrome'points it at the browser already installed. See the puppeteer API. - A local page with known defects, so the output is reproducible. This one has four: a
divthat acts as a button, a removed focus indicator, a visual order that disagrees with the DOM, and a field that swallows Tab. Save it asfocus-demo.mjs.
// focus-demo.mjs - a checkout page with four known keyboard defects. node focus-demo.mjs
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/'));
- The figures below come from one capture, on one machine, with Chrome 152.0.7977.76. Another Chrome version draws a different default ring.
- WCAG 2.1.1 Keyboard and WCAG 2.1.2 No Keyboard Trap are the two criteria this check maps to, both Level A.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node focus-demo.mjshttp://localhost:8794/ - Step 2.
Walk the page with Tab and print every stop. The loop descends into open shadow roots, which matters in step 5.
// tab-stops.mjs - node tab-stops.mjs <url> <presses> import { launch } from 'puppeteer-core'; const [url, n = '12'] = process.argv.slice(2); const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); 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(() => { let e = document.activeElement, path = []; while (e) { path.push(e.tagName.toLowerCase() + (e.id ? '#' + e.id : '')); if (!e.shadowRoot?.activeElement) break; e = e.shadowRoot.activeElement; } const c = getComputedStyle(e); const label = (e.textContent || e.getAttribute('aria-label') || '').trim().slice(0, 24); return `${path.join(' >> ')} "${label}" outline=${c.outlineWidth} ${c.outlineStyle}`; })); } await browser.close();node tab-stops.mjs http://localhost:8794/ 141. input#news "" outline=1px auto 2. a "Skip to content" outline=1px auto 3. input#coupon "" outline=1px auto 4. button#back "Back" outline=1px auto 5. button#next "Continue" outline=1px auto 6. button#cancel "Cancel" outline=1px auto 7. button#save "Save card" outline=3px none 8. button#fancy "Fancy" outline=3px none 9. input#card "" outline=1px auto 10. input#card "" outline=1px auto … 14. input#card "" outline=1px autoNine stops for ten interactive elements, and the last six presses go nowhere.
- Step 3.
Name the control the sequence skipped. Anything that answers a click and is not focusable belongs in this list.
// unreachable.mjs - node unreachable.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('clickable but not focusable:'); console.log(await page.evaluate(() => { const focusable = 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])'; return [...document.querySelectorAll('[onclick],[role=button],[class*=btn]')] .filter((e) => !e.matches(focusable)) .map((e) => ` ${e.tagName.toLowerCase()}#${e.id || '-'} "${e.textContent.trim()}" tabIndex=${e.tabIndex} role=${e.getAttribute('role') ?? '(none)'}`) .join('\n') || ' (none)'; })); const { nodes } = await (await page.createCDPSession()).send('Accessibility.getFullAXTree'); console.log('roles Chrome computed for Apply and Back:'); for (const n of nodes.filter((n) => /^(Apply|Back)$/.test(n.name?.value ?? '') && n.role.value !== "InlineTextBox")) console.log(` "${n.name.value}" -> ${n.role.value}`); await browser.close();node unreachable.mjs http://localhost:8794/clickable but not focusable: div#apply "Apply" tabIndex=-1 role=(none) roles Chrome computed for Apply and Back: "Back" -> button "Apply" -> StaticText "Back" -> StaticTextChrome files
Applyas text. It has no role, no tab stop and no keyboard activation, and a mouse pass shows none of that. - Step 4.
Land on the field that ate the last six presses and try every ordinary way out.
// trap.mjs - node trap.mjs <url> <presses-to-reach-the-field> 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' }); const at = () => page.evaluate(() => `${document.activeElement.tagName.toLowerCase()}#${document.activeElement.id || '-'}`); for (let i = 0; i < Number(n); i++) await page.keyboard.press('Tab'); console.log(`${n} x Tab : ${await at()}`); for (const key of ['Tab', 'Escape', 'Enter']) { await page.keyboard.press(key); console.log(`${key.padEnd(9)}: ${await at()}`); } await page.keyboard.down('Shift'); await page.keyboard.press('Tab'); await page.keyboard.up('Shift'); console.log(`Shift+Tab: ${await at()}`); await browser.close();node trap.mjs http://localhost:8794/ 99 x Tab : input#card Tab : input#card Escape : input#card Enter : input#card Shift+Tab: input#cardFour exits tried, focus never moves. That is the Level A failure, and it needs no screen reader to prove.
- Step 5.
Run the same script against a page that passes, to see what a clean sequence looks like.
node tab-stops.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP 61. a "Skip to main content" outline=1px auto 2. a "Skip to search" outline=1px auto 3. mdn-placement-top >> a "Scrimba" outline=1px auto 4. a "MDN" outline=1px auto 5. button "HTML" outline=1px auto 6. button "CSS" outline=1px autoTwo skip links first, then the header in source order. Stop 3 is the case the loop was written for: read on.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Fewer stops than interactive controls | Something clickable has no tab stop | Run step 3 to name it, then replace the wrapper with a button |
| The same element on consecutive presses | Focus cannot leave it | A handler is cancelling Tab. Fix that before anything else |
| A stop on a wrapper with tabindex=-1 | The real control is inside an open shadow root | Descend with shadowRoot.activeElement before reading the node |
| A stop whose position jumps backwards | The tab sequence disagrees with the layout | Measure it properly with the focus order check |
| outline=3px none on a stop | The indicator is off on that control | Confirm with the focus indicator check, which reads the rule that did it |
Common mistakes
What to check next
- How to check focus order: the same tab sequence, measured against where the controls sit on screen.
- How to check visible focus indicator: reads why stops 7 and 8 came back as
outline=3px none. - How to test a focus trap in a modal: the deliberate version of step 4, where focus is meant to stay inside a dialog.
- How to test a skip to content link: the first stop on the MDN capture, and what it has to do when activated.
FAQ
How to check for keyboard traps?
Keep pressing Tab well past the last control, then try Shift+Tab, Escape and Enter from wherever you land. If all four leave focus on the same element, it is a trap under WCAG 2.1.2, Level A. Step 4 runs exactly that sequence and prints the element after each key.
How to test keyboard navigation without writing a script?
Press Tab through the page and watch the ring. It finds unreachable controls and traps, which are the two failures that matter most. It misses the shadow root case and it gives you nothing to attach to a ticket, which is why the script prints element ids.
How to check keyboard accessibility on a single component?
Point the script at the page, count the presses that reach the component, and read only those lines. Tab stop counts move when the surrounding page changes, so record the count with the result or the next run will disagree with this one.
Which keys besides Tab should I press?
Enter and Space on every stop that acts like a button, arrow keys inside radio groups, menus and tab lists, and Escape in anything that overlays the page. Tab alone proves reachability, not operability.
Does a positive tabindex break keyboard navigation?
It reorders it. In the capture above, tabindex="1" on the newsletter checkbox pulls it in front of the skip link, so the first press lands at the bottom of the page. The elements stay reachable, which is why this shows up as a focus order failure rather than a keyboard one.
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
intermediate12 minpublished updated Maks Verny