Section 3 · Keyboard, Focus and ARIA
Managing Focus: Dialogs and Single-Page Apps
20 min3 min readCode playground
Lesson at a glance
Move keyboard focus deliberately when content changes without a page load, using the native dialog element, the inert attribute and focus management on route changes.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
On a traditional website, the browser looks after focus for you. Every click on a link loads a new page, focus resets to the top, and the screen reader announces the new title. Ticketly is a single-page app with modals, drawers and client-side routing, so none of that happens. When your code changes the screen, your code decides where focus goes. If it doesn’t decide, focus stays wherever it was, often on something now hidden, covered or deleted.
The modal that traps nobody
Ticketly’s “Book now” button opens a booking modal: a div with a dark overlay. Watch what happens with a keyboard. The modal appears, but focus is still on “Book now” behind it. Tab moves to the next festival link on the page underneath. Samir’s screen reader says nothing about a modal; to him, nothing happened.
A correct modal dialog needs five behaviours:
- Focus moves into the dialog when it opens.
- Tab and Shift+Tab stay inside it while it is open.
- The page behind is hidden from assistive technology.
- Escape closes it.
- Focus returns to the control that opened it.
For years this took a focus-trap library and fifty lines of code. Today the native <dialog> element opened with showModal() does all five.
<button type="button" id="book">Book now</button>
<dialog id="booking" aria-labelledby="booking-title">
<h2 id="booking-title">Book Friday pass</h2>
<label for="qty">Quantity</label>
<input id="qty" type="number" min="1" max="6" value="1">
<button type="button" id="confirm">Confirm</button>
<button type="button" id="close">Close</button>
</dialog>const dialog = document.querySelector('#booking');
document.querySelector('#book').addEventListener('click', () => dialog.showModal());
document.querySelector('#close').addEventListener('click', () => dialog.close());showModal() places the dialog in the top layer, above everything, and makes the rest of the document inert: unfocusable, unclickable and absent from the accessibility tree. Focus goes to the element with autofocus if there is one, otherwise to the first focusable element inside (here the quantity field), otherwise to the dialog itself. Escape closes it. When it closes, the browser returns focus to the element that had it before, the “Book now” button. The ::backdrop pseudo-element styles the overlay.
Give every dialog a name with aria-labelledby pointing at its heading. When it opens, NVDA reads “Book Friday pass, dialog, Quantity, spin button, 1”, which tells Samir exactly where he is. If the dialog’s first field isn’t the best starting point, say a long terms dialog, put autofocus on the element that should receive focus, such as its Close button, or its heading made focusable with tabindex="-1" so reading starts at the top.
inert for everything else
Not every hidden panel is a dialog. Ticketly’s mobile menu slides off-screen when closed, but its links are still in the DOM and still focusable, so keyboard users tab through eight invisible links. The inert attribute switches a whole subtree off: no focus, no clicks, no accessibility tree.
<nav id="mobile-menu" class="drawer" inert>…</nav>function setMenuOpen(open) {
menu.inert = !open;
menu.classList.toggle('drawer--open', open);
toggle.setAttribute('aria-expanded', String(open));
}inert has been supported in all major browsers since 2023. Use it for closed drawers, off-screen carousel slides and content behind a custom overlay you can’t convert to <dialog>.
Focus after route changes and deletions
When Ticketly’s router swaps the home page for the checkout page, the link the user activated disappears, and focus falls back to <body>. The next Tab starts from the top of the document, and the screen reader announces nothing. Do what a page load would have done:
function afterNavigate() {
const heading = document.querySelector('main h1');
document.title = `${heading.textContent} · Ticketly`;
heading.setAttribute('tabindex', '-1');
heading.focus();
}Focusing the h1 announces the new page’s name and puts the user at the start of its content. tabindex="-1" makes the heading focusable from script without adding a Tab stop. Some frameworks do part of this for you; check what yours does before adding your own.
The same principle applies to deletions. When a user removes a ticket from the basket, the “Remove” button they pressed is gone. Move focus somewhere sensible, such as the next item’s Remove button or the basket heading, instead of letting it drop to the top of the page.
Native elements and a few lines of focus management cover most of Ticketly. When there is no native element, you need ARIA, which is where the next lesson goes.
Key takeaways
- When your script changes what is on screen, it is responsible for where focus goes; the browser only manages focus on full page loads.
- A dialog opened with showModal() moves focus inside, makes the rest of the page inert, closes on Escape, and returns focus to the opener when it closes.
- The inert attribute removes a whole subtree from focus, clicks and the accessibility tree, which is ideal for closed drawers and off-screen panels.
- After a client-side route change, update document.title and move focus to the new page’s h1 (with tabindex=“-1”).