■ Focus basics and the focus trap
Three short experiments. You only need a keyboard. Press Tab to move, Shift+Tab to move back, Enter to activate.
1. What focus actually is
Focus is the browser keeping track of which element is currently active. When you press Tab, focus moves from one interactive element to the next in page order. When you press Enter, the focused element activates.
Keyboard users and screen reader users navigate almost entirely through focus. If focus is invisible, or if it jumps somewhere unexpected, the page is unusable for them. Sighted mouse users rarely notice focus at all, which is exactly why it gets neglected.
:focus vs :focus-visible
:focus matches any focused element, even right after a mouse click. :focus-visible only matches when the browser decides a visible indicator is needed, which is usually keyboard navigation. Best practice: style :focus-visible so mouse users never get a ring they did not ask for, while keyboard users always get one.
Click the first button with your mouse and you get a focus ring anyway. Click the second one and you do not. Now Tab to each of them with the keyboard: both show a ring. That is :focus-visible doing its job.
2. The broken modal
Broken: no focus management
This dialog does nothing about focus. Open it, then press Tab. Focus stays on the button behind the overlay and Tab walks through the entire page behind the dialog. If you could not see the screen, you would have no idea a dialog is even open.
This is what a large share of modals on the web do today. The overlay is visual only. Keyboard users are left behind it.
3. The fixed modal
Fixed: proper focus trap
This dialog does three things: it moves focus inside when it opens, it keeps Tab cycling within it, and when it closes it returns focus to the button that opened it. Escape closes it too.
The trap is a short keydown handler. When Tab would leave the last element, jump to the first. When Shift+Tab would leave the first, jump to the last:
function trapTab(event, dialog) {
const items = Array.from(
dialog.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter(el => !el.disabled && el.getClientRects().length > 0);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
And the open and close wiring:
let opener = null;
function openDialog() {
opener = document.activeElement; // remember where we came from
overlay.hidden = false;
dialog.focus(); // move focus inside
}
function closeDialog() {
overlay.hidden = true;
if (opener) opener.focus(); // put focus back where it was
}
The whole recipe
- Move focus in when the dialog opens, so keyboard and screen reader users land inside it.
- Trap Tab so focus cycles within the dialog instead of escaping behind the overlay.
- Return focus to the element that opened the dialog when it closes, so nobody gets dropped at the top of the page.
Bonus: role="dialog" with aria-modal="true" tells assistive technology that everything outside is inert while the dialog is open. That is the announcement layer on top of the focus behavior above.