← All writing
Craft · · 7 min

Focus management

The browser does this for free until you start swapping content, at which point it is quietly your job.

Accessibility JavaScript

I was testing a client’s product listing with the keyboard, because it was July and I’d spent the previous summer telling everyone to do that.

Tab to the filter. Space to apply it. The results change: 40 items become nine, the heading updates, everything works, it’s a nice bit of work.

And then Tab again, and focus went to the site logo. Back at the very top of the page, above the header, as though I’d just arrived. 41 presses of the Tab key to get back to where I’d been.

Nothing had gone wrong, exactly. The filter button I’d been focused on had been removed and rebuilt during the update, so focus had nowhere to go, so it fell to the body, which is where the browser puts it when the thing you were on ceases to exist. That’s the specified behavior. It’s just a terrible experience, and it’s completely invisible if you’re using a mouse.

What a page load does for free

Here’s the thing that took me years to see, because it’s an absence.

When you click a link and the browser loads a new document, it does several housekeeping jobs on your behalf. It resets focus to the start of the new document. It announces the new page’s title to assistive technology. It resets the scroll position. It gives the whole thing a fresh accessibility tree.

Nobody has ever had to implement any of that. It’s a property of the medium, like view source used to be, and it’s been quietly holding up the usability of the web for people who navigate by keyboard for as long as there’s been a web.

The moment you change content with JavaScript rather than loading a document, all of that stops happening and becomes your job. And the failure is silent: the visual result looks perfect, so nothing in your normal working day tells you anything is missing.

A page load does four jobs for you that nobody has ever had to implement. Swap the content with JavaScript instead and every one of them becomes yours, silently.

Route changes: the four lines

For a client-side route change, the fix is to do what the browser would have done. Move focus to the start of the new content, and make sure the change is announced.

route.js
function afterRouteChange(newTitle) {
  document.title = newTitle;                    // announced by some, and it's correct anyway
  const heading = document.querySelector('main h1');
  heading.setAttribute('tabindex', '-1');       // focusable by script, not by Tab
  heading.focus();
  window.scrollTo(0, 0);
}
JavaScript

The tabindex="-1" is the part people get wrong. A heading isn’t normally focusable, so focus() does nothing without it. Minus one means “focusable programmatically, but stay out of the tab order,” which is exactly the semantics you want. tabindex="0" would put every heading in the tab sequence and make the page worse for everybody.

Focusing the heading rather than a wrapper matters too: a screen reader announces what it lands on, so landing on the <h1> reads out the new page’s name, which is about as close as you’ll get to the real thing.

The same pattern covers the filter case from the top of this post. If the content region is replaced, move focus to something stable, or better, don’t destroy the control the person was using in the first place.

Modals, which everyone gets wrong

The other big one, and it has three parts, and most implementations do the first and neither of the others.

Move focus in. When the dialog opens, focus goes into it, usually to the first interactive element or to the dialog’s heading. Otherwise the person opens a modal and remains, as far as their keyboard is concerned, behind it.

Keep focus in. While it’s open, Tab must cycle within the dialog rather than wandering off into the page underneath. This is the part that’s genuinely fiddly by hand, and I have written the same 40-line focus trap on four projects.

Put focus back. On close, focus returns to whatever opened it. This one gets missed constantly and it’s the most annoying in practice: you close a dialog and you’re back at the top of the document, again.

modal.js
let lastFocused;

function open(dialog) {
  lastFocused = document.activeElement;         // remember who opened it
  dialog.hidden = false;
  (dialog.querySelector('[autofocus]') || dialog).focus();
  document.addEventListener('keydown', trap);
}

function close(dialog) {
  dialog.hidden = true;
  document.removeEventListener('keydown', trap);
  lastFocused.focus();                          // the line everybody forgets
}
JavaScript

Deleting the thing that has focus

The third case, and the one I see least written about.

If someone deletes a row from a table with the keyboard, and the delete button they just pressed was inside that row, then the button is gone and focus goes to the body, and they’re at the top of the document. Same failure as the filter, different clothes.

The fix is to decide where focus should go before you remove anything: the next row’s delete button, or the previous one if it was the last, or the container if the list is now empty. Three lines, and it turns a keyboard-hostile interface into a pleasant one.

The general rule I use: if you’re about to remove or replace something, ask where the person is standing, and if they’re standing on it, move them first.

And don’t remove the ring

All of the above is pointless if nobody can see where focus is.

Every reset stylesheet I’ve ever used removes the focus outline, and it’s been in mine since about 2012, because it’s ugly and it appears on mouse clicks where it looks like a bug. That instinct is understandable and the consequence is that keyboard users can’t see where they are.

The right answer is a focus style you’ve actually designed. Something with real contrast, that isn’t relying on color alone, that survives on both light and dark backgrounds.

There’s a selector coming that matches only when the browser thinks a focus ring should be visible, which is the distinction everybody actually wanted: keyboard yes, mouse click no. It’s not widely supported yet, and there’s a polyfill that does the job in the meantime by putting a class on the document. Until it lands, my rule is that if you can’t be bothered with the polyfill, keep the default outline and style it rather than removing it. An ugly ring beats no ring by a distance nobody who uses a mouse can see.

Why it doesn’t get done

None of this is difficult. It’s four lines for a route change, about 40 for a dialog, and three for a deletion.

The reason it doesn’t get done isn’t difficulty, it’s that the failure is invisible to the person building it. So the only real fix is the one from last summer: put the mouse down, use the site, and watch what happens to the little box that says where you are.

Read similar posts
6 min

Heading levels are an outline

On a page I audited last month the only `h1` was the logo, the section headings were all `h4` because `h4` was the right size, and the actual page title was a `div` with a class on it.

6 min

details and summary

An FAQ page arrived on my desk with about 200 lines of JavaScript running an accordion, and the two elements that do it natively have been in every browser since 2020 and got the missing feature last winter.