← All writing
Craft · · 9 min

Focus went to the logo

A page load quietly does a few small jobs on your behalf, and the moment you swap content with JavaScript instead, every one of them is yours.

Accessibility JavaScript

I applied a filter on a client’s product listing, pressed Tab, and landed on the site logo.

Not the next filter. The logo, at the very top of the page, above the header, the first focusable thing in the document, as though I’d just arrived from somewhere else.

I was going through it with the keyboard because I’d spent last summer telling clients to do that to their own sites and it seemed like a real bozo move to skip it on my own work. Tab to the filter, space to apply it. 40 products became 9, the heading updated, the count updated, the URL updated. Everything on screen was right.

Then Tab again, and the logo. I counted the way back. 34 presses to get to where I’d been standing.

Nothing had gone wrong, technically. The script rebuilds the whole listing region when a filter changes, sidebar included, so the button I’d been focused on was removed from the document and a new one put in its place. The browser did the only thing available to it with a focused element that no longer exists, which is drop focus onto the body and start counting from the top again. That’s specified behavior. document.activeElement said <body> and it was telling the truth.

It took me longest to see that the new button was identical. Same place, same label, same styles, indistinguishable in a screenshot. A different button, with the same everything. And with a mouse none of it exists, because a mouse doesn’t care where the browser thinks you are, which is most of the reason this survives code review, QA, client sign-off and three years of production traffic.

Housekeeping

I’d never noticed an absence in all this, which is a hard shape to notice.

When you click a link and the browser loads a document, it does a handful of small jobs for you on the way. It resets focus to the start of the new page. It gives assistive technology a new title to announce. It resets the scroll position. It throws away the old accessibility tree and builds a fresh one.

Nobody has ever had to write any of that. It’s a property of the medium, like view source. It’s been holding up the whole experience for people who navigate by keyboard for as long as there’s been a web to navigate.

Change content with JavaScript instead of loading a document and every one of those becomes your job, silently, with no warning at the moment of the trade. The visual result looks correct, so nothing in a normal working day tells you a thing is missing. You find out because somebody using the site tells you, or because you sat down and used it the hard way, and mostly people don’t do either.

Do what the browser would have done

For a client-side route change the fix is to imitate the thing you replaced. Put the person at the start of the new content and make sure the change gets announced.

route.js
function afterRouteChange(title) {
  document.title = title;

  const heading = document.querySelector('main h1');

  // focusable by script, still out of the tab order
  heading.setAttribute('tabindex', '-1');
  heading.focus();

  window.scrollTo(0, 0);
}
JavaScript

tabindex="-1" is the part I see people get wrong, in both directions. A heading isn’t focusable on its own, so focus() does nothing at all without it and you get a fix that appears to work because the page happened to be scrolled to the top anyway. Minus one means focusable by script, absent from the tab sequence, which is precisely the semantics you want here. Zero would put every heading on the site into the tab order and make the page worse for everybody, including the people you were trying to help.

Focus the heading and not the wrapper around it, because a screenreader announces whatever it lands on, so landing on the <h1> reads out the name of the thing you just navigated to. That’s about as close to a real page load as you’re going to get without doing a real page load.

Three parts, and everybody does the first one

Dialogs are the other big one and they have three moving parts.

Move focus in. On open, focus goes into the dialog, either to the first interactive thing in it or to its heading. Skip this and the person opens a modal and stays, as far as their keyboard is concerned, behind it, pressing Tab through a form they can see and cannot reach.

Keep focus in. While it’s open, Tab has to cycle inside the dialog instead of wandering off into the page underneath. This is the genuinely fiddly one. I’ve written the same 40-line trap on a handful of projects now and I’ve never once gotten it right the first time, usually because of shift+tab off the front of the list, which everybody tests last.

Put focus back. On close, focus returns to whatever opened it. This is the line everybody forgets, me twice. It’s the most disorienting of the three in practice, because closing a dialog puts you at the top of the document with no idea why.

modal.js
let lastFocused;

function open(dialog) {
  // remember who opened it
  lastFocused = document.activeElement;

  dialog.hidden = false;
  (dialog.querySelector('[autofocus]') || dialog).focus();
  document.addEventListener('keydown', trapTab);
}

function close(dialog) {
  dialog.hidden = true;
  document.removeEventListener('keydown', trapTab);

  // the line everybody forgets
  lastFocused.focus();
}
JavaScript

There’s a <dialog> element in the spec that’s meant to do all three of these for you, plus escape to close and a real backdrop. Right now it’s Chrome and nothing else, with Firefox behind a flag and Safari not in the conversation. So it isn’t an answer yet. I’d like it to become one, because the trap above is the same bad afternoon on every project and nobody’s version of it is better than anybody else’s.

Where are they standing?

The third case is the one from the top of this post wearing different clothes, and it’s the one I see written about least.

Somebody deletes a row from a table with the keyboard. The delete button they just pressed was inside the row. The row goes, so the button goes, so focus goes to the body. So they’re at the top of the document with 34 presses ahead of them and no explanation. Same failure, and it turns up anywhere something can be removed, which by now is most things.

The fix is to decide where focus is going before you take anything away. The next row’s delete button, or the previous row’s if you just deleted the last one, or the empty-state message if there are no rows left. It’s three lines and it’s the difference between a table that’s pleasant to use with a keyboard and one that punishes you for every action.

Which generalizes into the only rule from all of this I actually carry around. Before you remove or replace something, ask where the person is standing, and if they’re standing on it, move them first. Forms are the same shape from the other side: when validation fails you move focus to the first bad field, which is one line at the end of the submit handler and does more for that form than any amount of error styling.

outline: none

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

Every reset stylesheet I’ve used since I started has removed the focus outline. I’ve typed those two words myself, on purpose, on more projects than I want to count. The instinct isn’t stupid. The default ring is ugly, it doesn’t match anybody’s design, and it turns up on mouse clicks where it reads as a rendering bug, so somebody deletes it, and the consequence lands entirely on people who weren’t in the room.

The right answer is a focus style you designed. Real contrast against whatever it sits on, not carried by color alone, thick enough to find without hunting.

base.css
:focus {
  /* no color, so it takes currentColor and works on any background */
  outline: 2px solid;
  outline-offset: 2px;
}
CSS

There’s a pseudo-class on the way that draws the distinction everybody actually wanted, which is keyboard yes, mouse click no. Firefox has had its own prefixed version of it for years, Chrome has it behind the experimental features flag, and :focus-visible unprefixed isn’t in anything you can use in production this year. There’s a polyfill that does the job in the meantime by putting a class on the document, and it’s fine.

Until it lands properly, my rule is that if you don’t want to bother with the polyfill, keep the default ring and style it instead of deleting it. An ugly outline beats no outline by a distance that’s completely invisible to the person using a mouse and completely obvious to everybody else.

Put the mouse down

None of this is hard. Four lines for a route change, three for a deletion, one for a form, and the dialog is a bad afternoon you only have to have once.

The listing page didn’t get any of the code above, as it turns out. The real fix was to stop rebuilding the sidebar, because the filters hadn’t changed and there was no reason to redraw them, so the button somebody is standing on survives the update and focus never goes anywhere. Duller than moving focus by hand, and cheaper. I’d reach for it first now. Half of this work is noticing you destroyed something you didn’t need to destroy.

It doesn’t get skipped because it’s difficult, it gets skipped because nothing tells you. The page looks right, the tests pass, the client signs it off, and the only people who find out are the ones who were never going to be in the meeting. Last summer I was telling clients to tab through their own sites. This year I’ve started doing it to my own work before I hand it to anyone, which is slower, and which found this one.

Read similar posts
9 min

h4 was the right size

I pulled up the list of headings on a client's page and got the company name and three things at level four, with the actual subject of the page missing from the list entirely because somebody had built it out of a div.

7 min

Printing an accordion

A customer saved a client's returns page as a PDF and got back a column of questions with white space under every one of them, which is what an accordion does when it meets a printer.