EduFlow

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.

  1. 1The modal that traps nobody
  2. 2inert for everything else
  3. 3Focus after route changes and deletions

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:

  1. Focus moves into the dialog when it opens.
  2. Tab and Shift+Tab stay inside it while it is open.
  3. The page behind is hidden from assistive technology.
  4. Escape closes it.
  5. 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.

ticketly/booking.html
<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>
ticketly/booking.js
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.

Focus starts on the Book now button. showModal moves it to the first field in the dialog, where Tab cycles within the dialog while the page is inert. Close or Escape returns focus to the Book now button. "Book now" dialog (top layer) Quantity → Confirm → Close → Quantity showModal() close / Esc Page behind inert Tab never leaves the dialog while it is open
Focus during a dialog’s life

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.

HTML
<nav id="mobile-menu" class="drawer" inert>…</nav>
JavaScript
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:

ticketly/router.js
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”).

Further reading

Check your understanding

4 questions

Question 1 of 4

A keyboard user presses Enter on “Book now”, which runs this code. Where is focus afterwards?

JavaScript
bookButton.addEventListener('click', () => {
  document.querySelector('.modal').hidden = false; // a div styled as a modal
});
Question 2 of 4

Which of these does dialog.showModal() NOT do for you?

Question 3 of 4

Ticketly’s mobile menu slides off-screen when closed, but Tab still visits its eight links while they are invisible. What is the simplest robust fix?

Question 4 of 4

After a client-side navigation from the home page to “/checkout”, what should Ticketly’s router do for screen reader users?

Practice

Code playground

Turn the booking modal into a real dialog

Ticketly’s booking modal is a div that appears when you press Book now. Focus stays behind it, the page underneath stays reachable, and screen readers aren’t told a dialog opened.

Convert #booking into a native <dialog> (keep the id) with an accessible name taken from its heading “Book Friday pass”. Open it as a modal from the Book now button and close it from the Close button. When it closes, focus should be back on Book now.

<main>
  <h1>Harbour Lights: Friday</h1>
  <p>Standing tickets, SAR 180 each.</p>
  <button type="button" id="book">Book now</button>
  <a href="#faq">Festival FAQ</a>
</main>

<div class="modal" id="booking" hidden>
  <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>
</div>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • #booking is a named “Book Friday pass”
  • Book now opens it as a modal dialog
  • Focus moves inside the dialog when it opens
  • The page behind can’t receive focus while the dialog is open
  • Close closes the dialog and focus returns to Book now