How to test a focus trap in a modal
Open the dialog from the keyboard, then press Tab and Shift+Tab past both ends, press Escape, and close it. A correct dialog keeps every stop inside itself in both directions, closes on Escape, and returns focus to the control that opened it. The script below reports all four.
Why check this
Run this on every dialog, drawer and overlay before release, and again when a component library is upgraded, because a trap is a few lines of key handling that a refactor quietly drops. Focus leaves the dialog, lands on the page behind, and there is nothing on screen to say where the next keystroke will go, because the overlay is drawn on top. In the capture below a keyboard user signs out of the application while the settings dialog is still open.
Four separate behaviours are involved, and a dialog that gets one right usually gets the others wrong. Tab has to stay inside, Shift+Tab has to stay inside, Escape has to close, and closing has to put focus back where it came from. Test all four, in that order, and treat them as four results rather than one verdict.
Prerequisites
- Node 22 and Chrome on one machine, plus
npm i puppeteer-core. See the puppeteer API. - A local page with two dialogs. The first is hand-rolled and holds Tab only. The second is a native
<dialog>opened withshowModal(), with no focus script at all. Save it asmodal-demo.mjs.
// modal-demo.mjs - two dialogs, one hand-rolled and one native. node modal-demo.mjs
import { createServer } from 'node:http';
const shell = (dialog, s) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Modal demo</title><style>
body{font:16px system-ui;margin:2rem} #dlg{border:1px solid #999;padding:1rem;max-width:20rem} [hidden]{display:none}
</style></head><body>
<a href="/help">Help</a>
<button id="open">Open settings</button>
<button id="after">Sign out</button>
<output id="log"></output>
${dialog}
<script>
const dlg = document.getElementById('dlg');
document.getElementById('open').addEventListener('click', () => ${s.open});
document.getElementById('close').addEventListener('click', () => ${s.close});
document.getElementById('after').addEventListener('click', () => { log.textContent = 'signed out'; });
${s.extra ?? ''}
<\/script></body></html>`;
const fields = `<h2>Settings</h2><label>Name <input id="name"></label>
<button id="save">Save</button><button id="close">Close</button>`;
const routes = {
// Hand-rolled. Tab is wrapped at the last control. Shift+Tab is not handled,
// Escape does nothing, and nothing gives focus back to the opener.
'/': shell(`<div id="dlg" role="dialog" aria-modal="true" aria-label="Settings" hidden>${fields}</div>`, {
open: `{ dlg.hidden = false; document.getElementById('name').focus(); }`,
close: `{ dlg.hidden = true; }`,
extra: `dlg.addEventListener('keydown', e => {
if (e.key === 'Tab' && !e.shiftKey && document.activeElement.id === 'close') {
e.preventDefault(); document.getElementById('name').focus();
}
});`,
}),
// Native. No focus script at all.
'/native': shell(`<dialog id="dlg" aria-label="Settings">${fields}</dialog>`, {
open: `dlg.showModal()`, close: `dlg.close()`,
}),
};
createServer((req, res) => res.end(routes[req.url] ?? routes['/']))
.listen(8842, () => console.log('http://localhost:8842/'));
- The figures are one capture on one machine, Chrome 152.0.7977.76. Focus handling in a native dialog has changed between Chrome versions, so record the version with the result.
- WCAG 2.1.2 No Keyboard Trap is Level A and requires a keyboard way out. The ARIA dialog pattern describes the four behaviours tested here.
Steps
- Step 1.
Start the demo server in its own terminal.
node modal-demo.mjshttp://localhost:8842/ - Step 2.
Run the four assertions against the hand-rolled dialog. The script opens it with Enter on the opener, so the reading describes a keyboard user and not a mouse click.
// focus-trap.mjs - node focus-trap.mjs <url> <opener> <dialog> import { launch } from 'puppeteer-core'; const [url, opener = '#open', box = '#dlg'] = process.argv.slice(2); const browser = await launch({ channel: 'chrome', headless: true }); const page = await browser.newPage(); const at = () => page.evaluate((d) => { let e = document.activeElement; while (e?.shadowRoot?.activeElement) e = e.shadowRoot.activeElement; if (!e || e === document.body) return '(body)'; const id = e.tagName.toLowerCase() + (e.id ? '#' + e.id : ''); return document.querySelector(d).contains(e) ? id : id + ' [OUTSIDE]'; }, box); const open = () => page.evaluate((d) => { const e = document.querySelector(d); return e.tagName === 'DIALOG' ? e.open : !e.hidden; }, box); const start = async () => { await page.goto(url, { waitUntil: 'networkidle2' }); await page.focus(opener); await page.keyboard.press('Enter'); }; const walk = async (n, shift) => { const seq = []; for (let i = 0; i < n; i++) { if (shift) { await page.keyboard.down('Shift'); await page.keyboard.press('Tab'); await page.keyboard.up('Shift'); } else await page.keyboard.press('Tab'); seq.push(await at()); } return seq.join(' -> '); }; await start(); console.log('after open : ' + await at() + ' dialog open=' + await open()); console.log('Tab x5 : ' + await walk(5, false)); await start(); console.log('Shift+Tab x5 : ' + await walk(5, true)); const settle = () => new Promise((r) => setTimeout(r, 200)); await start(); await page.keyboard.press('Escape'); await settle(); console.log('Escape : dialog open=' + await open() + ' focus=' + await at()); await start(); await page.evaluate(() => document.querySelector('#close').click()); await settle(); console.log('Close clicked: dialog open=' + await open() + ' focus=' + await at()); await browser.close();node focus-trap.mjs http://localhost:8842/after open : input#name dialog open=true Tab x5 : button#save -> button#close -> input#name -> button#save -> button#close Shift+Tab x5 : button#after [OUTSIDE] -> button#open [OUTSIDE] -> a [OUTSIDE] -> (body) -> button#close Escape : dialog open=true focus=input#name Close clicked: dialog open=false focus=(body)One of four passes. Tab wraps from Close back to Name. The first Shift+Tab is already outside, Escape leaves the dialog open, and closing drops focus to the body.
- Step 3.
Turn the leak into a consequence. Press Shift+Tab once from inside the open dialog and activate whatever is there.
// behind-modal.mjs - node behind-modal.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' }); await page.focus('#open'); await page.keyboard.press('Enter'); await page.keyboard.down('Shift'); await page.keyboard.press('Tab'); await page.keyboard.up('Shift'); console.log('focus after Shift+Tab : ' + await page.evaluate(() => document.activeElement.id || '(body)')); await page.keyboard.press('Enter'); await new Promise((r) => setTimeout(r, 200)); console.log('background log : "' + await page.evaluate(() => document.getElementById('log').textContent) + '"'); console.log('dialog still open : ' + await page.evaluate(() => { const e = document.querySelector('#dlg'); return e.tagName === 'DIALOG' ? e.open : !e.hidden; })); await browser.close();node behind-modal.mjs http://localhost:8842/focus after Shift+Tab : after background log : "signed out" dialog still open : trueTwo keystrokes from inside a modal dialog signed the user out of the application, with the dialog still on screen.
aria-modal="true"is a name for the layer, not a barrier. - Step 4.
Run the same four assertions against the native
<dialog>, which carries no focus script.node focus-trap.mjs http://localhost:8842/nativeafter open : input#name dialog open=true Tab x5 : button#save -> button#close -> (body) -> input#name -> button#save Shift+Tab x5 : button#close -> button#save -> input#name -> (body) -> button#close Escape : dialog open=false focus=button#open [OUTSIDE] Close clicked: dialog open=false focus=button#open [OUTSIDE]Four of four, with no key handling written. Escape closes the dialog and focus returns to the opener, which is why the
[OUTSIDE]marker on the last two lines is the passing result. One stop per revolution reports(body), in both directions. - Step 5.
Repeat the leak test on the native dialog, to confirm the background is not reachable.
node behind-modal.mjs http://localhost:8842/nativefocus after Shift+Tab : (body) background log : "" dialog still open : trueShift+Tab reaches the body stop, Enter there does nothing, and the sign-out handler never runs.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Every stop inside the dialog or (body) | Focus is contained | Nothing on containment |
| An [OUTSIDE] stop while the dialog is open | The trap leaks in that direction | Handle the same key with shiftKey both true and false |
| Escape : dialog open=true | No key handler for Escape | Add one, or move to a native dialog |
| Close clicked: focus=(body) | The opener was not restored | Store the element before opening and focus it after closing |
| [OUTSIDE] on the Escape and Close lines | Focus went back to the opener | This is the passing result for those two lines |
Common mistakes
What to check next
- How to test keyboard navigation on a website: the unintended version of this check, where a field holds focus and nothing releases it.
- How to check focus order: the dialog replaces the page order with its own while it is open.
- How to check aria hidden elements are not focusable: hiding the background with
aria-hiddeninstead ofinertleaves every control behind the dialog focusable. - How to test a skip to content link: the other control whose job is to move focus rather than to hold it.
FAQ
How to trap focus in a modal?
Use a native <dialog> and open it with showModal(). Chrome contains Tab and Shift+Tab, closes on Escape and returns focus to the opener, with no key handling in the page. Step 4 is that element with an empty script.
How to keep focus within a modal dialog?
If the dialog has to be a div, handle keydown for Tab in both directions, wrap at the first and the last focusable element, and mark the rest of the page inert while it is open. Then run step 2 against it, because the handler is where the defects live.
Does a native dialog still need a focus trap script?
Not for containment. It still needs an accessible name, an initial focus target that suits the content, and a check that close() is reached from every path out, including a click on the backdrop if the design allows one.
What should happen to focus when the dialog closes?
It returns to the control that opened it. A native dialog does that on its own, as the last two lines of step 4 show. A hand-rolled dialog has to store the element before opening and focus it after closing, or focus falls to the body and the next Tab starts from the top of the document.
Is Escape required?
The ARIA dialog pattern requires it for a modal dialog, and WCAG 2.1.2 requires some keyboard way out. Escape is the one people try first, so a dialog that closes only through its own Close button fails the pattern.
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