← All writing
Craft · · 6 min

tabindex

On the four values that matter, the one that breaks a whole document from inside a third-party script, and the attribute that finally arrived to replace the worst hack in this area.

Accessibility HTML

Tab, once, on a freshly loaded homepage, and focus landed inside a chat widget at the bottom of the page. Not the skip link, not the logo, not the navigation. A support widget, below the footer, off screen.

Somewhere in that widget’s markup was tabindex="3".

I’ve been meaning to write this one down for a while, because tabindex is four values and three of them are simple, and the fourth is super complex with implications for the entire document.

The best one is not writing it

The default behavior, with no attribute at all: interactive elements are focusable and appear in the tab order in DOM order. Links with an href, buttons, form fields, <select>, <textarea>, <summary>, iframes. Everything else is not focusable.

That’s correct almost all of the time, and it’s free, and it’s the option most tabindex attributes exist to work around.

So the first question when you’re reaching for this is always whether you’ve picked the wrong element. A <div> with a click handler needs a role, a tab stop, key handlers for Enter and Space, a focus style and a disabled state. A <button> needs none of those, because it comes with all of them and always has.

Most tabindex attributes exist to give an element the behavior a different element already had.

0: in the natural order

tabindex="0" puts a normally-unfocusable element into the tab sequence at its DOM position.

The legitimate case is a genuinely custom control with no native equivalent: a tree view, a grid with roving focus, a canvas-based thing. Real, and rarer than the number of tabindex="0" attributes in the wild suggests.

The other case, which is fine, is making a region focusable so people can reach it, though I’d usually reach for a heading and script rather than putting it in the tab order permanently.

If you find yourself typing tabindex="0", it’s worth ten seconds asking what the element is. About four times in five it’s a button.

-1: the useful one

tabindex="-1" makes an element focusable by script but leaves it out of the tab sequence.

This is the one I use constantly and it’s the whole basis of focus management. Move focus to a heading after a client-side navigation. Move it to an error summary after a failed submission. Move it into a dialog when it opens. In each case you need focus() to work on something that shouldn’t be a permanent tab stop, and this is exactly that.

focus.js
const heading = document.querySelector('main h1');
heading.setAttribute('tabindex', '-1');
heading.focus();
JavaScript

The one thing worth knowing: -1 on a container does not make its children unfocusable. It only affects the element itself. That misunderstanding has produced a lot of broken modals, where somebody put -1 on the page behind the dialog and assumed the links inside it were now unreachable, and they aren’t.

Positive numbers, and what they actually do

Now the one that caused the chat widget.

A positive tabindex doesn’t reorder within a component. It creates a separate, document-wide tab sequence that runs before everything in the natural order.

So the browser tabs through every positive value in ascending numeric order, across the whole document, regardless of where those elements are, and only then starts on the ordinary elements. One tabindex="1" anywhere on the page makes that element the first thing focused, wherever it is.

Which means it isn’t a local decision. It’s global, it can’t be scoped, and it composes with nothing. A third-party widget with positive values in it reorders your entire page and there is no defense, and you find out by pressing Tab, which nobody does.

It’s also almost always a symptom rather than a plan. Somebody wanted the tab order to go a particular way, and the reason it didn’t is the DOM order, and the correct fix is to move the element in the markup.

And CSS can cause the same problem

The under-discussed version of this, and it’s the one I now check for on every project.

Tab order follows the DOM. Visual order follows the CSS. Those used to be more or less the same thing, and modern layout deliberately decoupled them, which I was enormously pleased about when Grid shipped and which has a cost I didn’t write about at the time.

order on a flex item, row-reverse, column-reverse, or placing grid items explicitly all change what people see without changing what they tab through. So you get a page where focus jumps from the top-left to the bottom-right and back, and every element is in the “right” place according to the source, and the person using it has no idea why.

Nothing warns you. It looks perfect.

The rule I use: if a layout change makes the visual order differ from the source order by more than a swap of two adjacent things, either fix the source order or don’t do it. And test it with the keyboard afterward, which takes 30 seconds and is the only way to find it.

inert, finally

When I wrote about focus management three years ago, I mentioned a proposed attribute for marking a whole subtree as unreachable and said it wasn’t a plan yet.

It’s in all three engines now, as of the spring.

dialog.js
function openDialog() {
  mainContent.inert = true;    // everything inside is unfocusable, unclickable,
  dialog.hidden = false;       // and hidden from assistive technology
  dialog.querySelector('h2').focus();
}

function closeDialog() {
  mainContent.inert = false;
  dialog.hidden = true;
}
JavaScript

That replaces the 40-line focus trap I’ve written on four projects, and it does it properly: not just Tab, but clicks and the accessibility tree as well. It’s the correct primitive and it took about five years to arrive, and it deletes more JavaScript than anything else I’ve adopted this year.

Four values

Nothing, which is right nearly always. Zero, which usually means you picked the wrong element. Minus one, which is the tool for moving focus deliberately. And positive integers, which reorder your entire document from inside a script you didn’t write.

The chat widget, incidentally, was configurable. There was a setting. It had been left at the default by whoever installed it in 2019, and nobody had pressed Tab on that homepage in four years, including me 😬

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.