← All writing
Craft · · 9 min

tabindex, and the four values that matter

Three of the four are simple, and the fourth quietly rewrites the tab order of a whole document from inside a script you didn't write.

Accessibility HTML

I pressed Tab once on a client’s homepage, before I’d touched anything else on it, and focus landed in the support chat launcher down past the footer. Not the skip link, not the logo, not the first item in the nav, but a fixed-position widget that is the last thing in the source and mostly off screen until you scroll.

Somewhere in the markup that widget injects was tabindex="3".

tabindex is four states, if you count not typing it, and three of them are simple enough to forget. The fourth is a document-wide instruction dressed up as a local one. I think it’s the only attribute in HTML where one use of it, anywhere on the page, changes the behavior of everything else.

Nothing to see here

With no attribute at all, interactive elements are focusable and they come in the order they appear in the source. Links with an href, buttons, form fields, <select>, <textarea>, <summary>, iframes. Everything else isn’t focusable, which is also correct, because everything else isn’t interactive.

That’s the right behavior nearly all of the time, it’s free, and it’s what most of the tabindex attributes in the wild exist to work around.

So the first question, before you type the thing, is whether you picked the wrong element. A <div> with a click handler on it needs a role, a tab stop, key handlers for Enter and Space, a visible focus style and a disabled state, which is taking on focus management, key handling and a role, then doing two of the three. A <button> arrives with all of it and always has.

Congratulations, you’ve built a button

tabindex="0" puts a normally unfocusable element into the tab sequence, at its position in the source.

The legitimate case is a genuinely custom control with no native equivalent, so a tree view, a grid with roving focus, something drawn on a canvas. Those are real, and rarer than the number of tabindex="0" attributes out there would suggest.

If you find yourself typing it, it’s worth 10 seconds to stop and say out loud what the element is. Nearly every time, it’s a button.

The one I actually type

tabindex="-1" makes an element focusable by script and leaves it out of the tab sequence. It’s the whole basis of focus management.

Move focus to a heading after a client-side navigation. Move it to the error summary after a failed submit. Move it into a dialog when the dialog opens. Every one of those needs focus() to work on something that has no business being a permanent tab stop. This is exactly and only that.

People get tripped up by -1 on a container doing nothing whatsoever to what’s inside it.

page.html
<!-- -1 applies to this div and to nothing inside it -->
<div tabindex="-1">
  <a href="/help">Help</a>
  <button>Close</button>
</div>
HTML

Both of those are still tab stops. That misunderstanding is behind a lot of half-working modals, where somebody put -1 on the page behind the dialog, assumed everything underneath was now unreachable, and then wondered why Tab walked out of the dialog and into the page. Many such cases.

Take a number

Now the one that put focus in the footer.

A positive tabindex does not reorder anything inside your component. It creates a second tab sequence, document-wide, that runs before everything in the natural order. The browser walks every positive value in ascending numeric order, wherever those elements happen to be sitting, and only then starts on the ordinary ones.

It works like the ticket machine at the deli counter. Everybody holding a number goes first, in number order, no matter where they’re standing in the store, and the people who just walked in wait until the numbers run out.

the first four presses
1. chat launcher   tabindex="3", injected last, fixed to the bottom right
2. skip link       no tabindex, first thing in the body
3. logo
4. main nav, first item
Text

So one tabindex="1" anywhere makes that element the first thing focused, wherever it is. It isn’t a local decision, it can’t be scoped to a component, and it doesn’t compose with anything else on the page. Genuinely OP.

I wonder if somebody at the vendor numbered their launcher, their input and their send button 1, 2, 3 so that their own three controls would tab in order. On their demo page, where the widget is the only thing on it, that would have been perfectly true, and it’s a reasonable thing to test and get a pass on. The other two don’t exist until you open the panel, so out on a real site the 3 sits there by itself, at the front of the line, above everything anybody else wrote 💀

I wrote about third-party scripts a few years ago entirely in terms of seconds. This is the same shape of problem in a different unit, except the seconds at least turn up in a waterfall where somebody might see them. Nothing on your side reports this one.

And it’s nearly always a symptom instead of a plan. Somebody wanted the tab order to go a particular way, the reason it didn’t go that way is the source order, and the fix is to move the element in the markup. If I got to nerf one attribute in HTML, it would be this one.

Order, order

The version of this I now check for on every project. It gets discussed far less than the attribute does.

Tab order follows the source. What people see follows the CSS. Those two used to be more or less the same thing, and modern layout separated them deliberately: the week Grid launched I wrote that you could finally put the HTML in the order the content actually goes in and place it afterward. I was giddy about it. I did put one line in that post about a keyboard user tabbing in one order and reading in another, and a guess that I’d find the limit by getting it wrong on something real. This is the bill for that.

order on a flex item, row-reverse, column-reverse, placing grid items explicitly, all of them change what somebody sees without changing what they tab through.

filters.css
.filters {
  display: flex;
}

/* last on screen, still first on the keyboard */
.filters__clear {
  order: 1;
}
CSS

So focus jumps from the end of the row back to the start of it, and the person using the keyboard has no idea why. It’s not that the markup is wrong. The markup is fine, which is why nobody finds it. I’ve done this to a page myself more than once, because the CSS is where I’m already standing and moving the element means opening a template that other pages use.

Focus order is its own success criterion for exactly this reason. It’s one of the few in the guidelines a tool genuinely cannot evaluate for you, because whether an order preserves meaning is a judgment about the content.

The rule I use is crude. 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. Then tab through it, which takes 30 seconds and is the highest-yield check I know.

Rest in peace, focus trap

Three years ago, in the focus management post, I said the genuinely fiddly part of a dialog is keeping Tab inside it while it’s open, and that I’d hand-written the same 40-line trap on 3 different projects.

inert has been in all three engines since the spring.

dialog.js
function openDialog() {
  // everything in here is unfocusable, unclickable and gone from the accessibility tree
  mainContent.inert = true;
  dialog.hidden = false;
  // the heading needs -1 before focus() does anything
  heading.setAttribute('tabindex', '-1');
  heading.focus();
}

function closeDialog() {
  mainContent.inert = false;
  dialog.hidden = true;
  // the line everybody forgets, me four times
  lastFocused.focus();
}
JavaScript

That’s the trap gone, and it does the job properly instead of approximately. A hand-rolled trap only ever knew about the keyboard, so the page behind the dialog stayed clickable and stayed present for a screenreader. Mine also carried a list of focusable selectors, which went out of date the moment somebody put a <summary> on the page, and inert has no list in it to go out of date.

It’s the right primitive, it spent years being proposed and dropped and proposed again before it got here, and it has deleted more of my JavaScript this year than anything else I’ve picked up.

Go and press Tab

Four states, then. The right answer is nearly always no attribute at all, 0 usually means you reached for the wrong element, -1 is the tool for moving somebody deliberately and the only one I type on purpose, and a positive integer is a document-wide instruction that anybody’s script can hand you.

For the widget I wrote a line of JavaScript that runs after their script loads, finds anything with a positive tabindex and sets it back to 0. It’s a hack, it runs on every page load forever, and I’d do it again. The vendor’s support rep was gracious about it and said they’d raise it with their product team, which I’ve decided to believe.

I can’t help but look at the ratio on this one. One keypress to find, about half a day to work around. It had been live for months, because pressing Tab once on a page you’re responsible for is not on anybody’s list. It’s on mine now, right next to opening it in a private window, before I open DevTools.

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.