Structure, keyboard, and focus

Manage focus after change

Move focus only when context changes require it and return it to a sensible trigger afterward.

When the page changes under the user, focus has to go somewhere sensible. A mouse user sees the new modal and clicks in it. A keyboard or screen reader user is still standing where they were, and if you removed that element, they’re now nowhere: focus falls back to the body, and the next Tab starts from the top of the page.

Focus management is deciding, for every change, where focus goes and where it comes back to. It’s part of the interaction design, not a fix you add later.

The guest modal

The flawed page opens the “Add a guest” modal by toggling a class. Focus stays on the button behind the overlay. Tab moves through the page underneath. Escape does nothing.

Let’s rebuild it with the native dialog element, which handles most of this for us:

<button type="button" id="add-guest">Add a guest</button>

<dialog id="guest-dialog" aria-labelledby="guest-title">
  <h2 id="guest-title">Add a guest</h2>
  <label for="guest-name">Guest name</label>
  <input id="guest-name" name="guest">
  <button type="button" id="guest-save">Save guest</button>
  <button type="button" id="guest-cancel">Cancel</button>
</dialog>

Open it with showModal(), which is the modal version, and close it with close():

const dialog = document.querySelector('#guest-dialog')

document.querySelector('#add-guest').addEventListener('click', () => {
  dialog.showModal()
})

document.querySelector('#guest-cancel').addEventListener('click', () => {
  dialog.close()
})

showModal() moves focus into the dialog, makes everything outside it inert so Tab cannot escape, and closes on Escape. When it closes, the browser returns focus to the element that had it before, the “Add a guest” button. The aria-labelledby gives the dialog a name, so it’s announced as “Add a guest, dialog”.

Verify every step

Run through it with the keyboard and note where focus is after each action:

Space on "Add a guest"  ->  Guest name field ("Add a guest, dialog. Guest name, edit text")
Tab, Tab, Tab           ->  Save guest, Cancel, Guest name again (trapped inside)
Escape                  ->  dialog closed, focus on "Add a guest" button

By default the first focusable control receives focus. If you prefer the heading, give it tabindex="-1" and the autofocus attribute. Then repeat the run after a failed save: submit an empty name, and check that focus lands on the error message or stays on the field, not on the Save button.

Removed elements

The guest list has a Remove button on each row. On the flawed page, activating it removes the row, and focus goes with it. The next Tab starts from the logo.

Decide the destination before removing:

function removeGuest(row) {
  const next = row.nextElementSibling || row.previousElementSibling
  row.remove()
  const target = next ? next.querySelector('button') : document.querySelector('#add-guest')
  target.focus()
}

Focus moves to the next row’s Remove button, or back to “Add a guest” when the list is empty.

Not every update deserves focus

The opposite mistake is moving focus for every small change: a price recalculation, a character counter, a “saved” badge. That yanks the user away from what they were typing. My rule: move focus when the user’s context changed because of their own action, like opening a dialog or landing on an error summary. For everything else, leave focus alone and announce the change instead, which is the topic of the live regions lesson.

Try this on your own modal: open it, submit it with an error, close it, and reopen it using only the keyboard, writing down where focus is after each step.

Lesson completed