PROJECT 4
×00

Semantic HTML and Focus Order for Keyboard Accessibility

Part 2 · Frontend Fundamentals

Ask an agent for a dropdown and you will usually get one that works. Click it, it opens. Click away, it closes. Ship it and a slice of your users cannot use it at all, because it is built from divs and the keyboard never reaches it.

This is the part agents skip most, and it is also the part you can check fastest. Put your hands on the keyboard, press Tab, and walk the page. No tooling, no audit, about thirty seconds. Most of what follows is what that walk turns up.

I want to be plain about why this matters beyond doing right by people. Automated checkers catch somewhere between a third and a half of real problems. They are good at contrast and missing alt text. They cannot tell you whether the tab order makes sense, whether focus is trapped, or whether your custom widget is usable at all. So the gap they leave is exactly the gap you close by hand. That means the tab key is not a poor substitute for a real audit. It is the part a real audit cannot automate.

The div that should be a button

Here is the single most common thing an agent hands me.

<div class="btn" onclick="save()">Save</div>

It looks right. It is not a button in any sense that matters. Compare:

<button type="button" onclick="save()">Save</button>

The div loses six things at once, and you get all of them free from the second line:

You can rebuild all six by hand with role, tabindex, and key handlers for Enter and Space. I have done it. It is about fifteen lines, it is easy to get subtly wrong, and it is worse than the one line that ships with the browser. The rule I use: if it goes somewhere, it is a link; if it does something, it is a button. Nearly every custom control I have had to fix was a div that should have been one of those two.

Two panels headed "They look the same. Only one of
  them is a button." Both render an identical gray Save control. The left panel, built from a
  div, lists six crosses: not in the tab order, no Enter or Space activation, no role, no accessible
  name, disabled does nothing, inert inside a form. The right panel, a real button, shows the same six
  as ticks and carries a blue focus ring. At the foot of each panel the browser reports its own
  measurements: the div has tabIndex minus one, cannot receive focus, and no implicit role, while the
  button has tabIndex zero, can receive focus, and the role button.
They look identical. Only the real button is in the tab order, announces its role, and takes a focus ring.

The three lines at the bottom of each panel in that image are not typed in. The page measures itself when it loads and prints what the browser reports, which is why the div comes back with a tabIndex of -1 and no role.

Focus you can see

The second thing I find is a focus ring someone removed.

:focus { outline: none; }    /* please, no */

Somebody does this because the default ring is ugly on their design, and now nobody can tell where they are on the page. Keyboard users navigate by that ring. Taking it away without a replacement is like turning off the cursor.

The fix is to style it, not kill it:

:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

:focus-visible is the one to reach for. It shows the ring when someone tabs and stays quiet when someone clicks, which is the behavior people were trying to get when they reached for outline: none in the first place.

Two related points worth knowing, since the rules changed and people still quote the old ones. A visible focus indicator is a plain AA requirement and has been for years. WCAG 2.2 added a rule that focus must not be hidden behind your sticky header, also AA, and worth testing, because a sticky bar covering the focused element is easy to miss and common. The stricter rule about how thick and how contrasting the ring must be sits at AAA, so treat it as a good target rather than a bar you have to clear. And while you are in there, interactive targets should be at least 24 by 24 CSS pixels, which is AA and usually a one-line fix.

The order on screen is not the order of the page

This is the one that catches good developers, and it links straight back to the last piece. CSS can move things around. It does not move the reading order or the tab order. Those follow the markup.

.actions { display: flex; }
.primary { order: -1; }     /* looks first, is still second */

Tab through that and you reach the secondary button first, even though the primary one sits on the left. Same with row-reverse, and same with placing grid items out of source order.

Two panels headed "CSS moves the picture.
  The tab key still follows the markup." Each shows a row of three buttons with numbered badges
  giving the real tab order. In the left panel Save has been pulled to the front with order minus one,
  so the badges read two, one, three from left to right, and a line underneath reports that the tab
  order jumps backwards on screen. In the right panel Save is written first in the markup, so the
  badges read one, two, three and the line reports that the tab order matches what you see.
CSS moves the picture. The tab key still follows the markup.

Those badge numbers are read off the live page rather than drawn on. In the left panel Save sits furthest left on screen but carries badge two, because the markup still puts Cancel first, and the page checks its own geometry and reports that the order runs backwards.

So: use these to adjust a visual arrangement, and when the order itself is wrong, fix the markup. If you find yourself reordering heavily to get the layout you want, the markup is probably in the wrong order to begin with.

tabindex above zero always causes trouble

Three values, and only two of them are safe.

I have never seen a positive tabindex that was not a mistake. If something needs to come earlier, move it earlier in the markup.

Labels, headings, landmarks

Three smaller habits that pay for themselves.

Tie labels to inputs. A placeholder is not a label. It vanishes the moment someone types, it fails contrast on most designs, and screen readers treat it inconsistently.

<label for="email">Email</label>
<input id="email" type="email">

The for must match the id. Do that and clicking the word focuses the field too, which helps everyone.

Use headings as an outline, not as sizes. Pick h1 through h6 by rank, then style them. Screen reader users jump between headings to find their way, so skipping from h1 to h4 because the smaller one looked right leaves holes in the map. If you want a large h3, that is a CSS problem.

Wrap the page in landmarks. header, nav, main, footer. Four elements, and they let people jump straight to the content. main in particular is what a skip link should point at.

What the tab key finds in thirty seconds

Walk any page you have shipped recently and look for five things:

  1. Can you reach every control at all? Anything you skip past is a div that should be a button.
  2. Can you see where you are on every stop? If the ring disappears, someone removed it.
  3. Does the order make sense, roughly following the page? If it jumps around, check for order, reversed containers, or a positive tabindex.
  4. Can you activate everything with Enter, and buttons with Space?
  5. Once you open a menu or dialog, can you get out again with Escape, and does focus go somewhere sensible rather than back to the top?

That last one is the most common failure I find in agent-written code, and it is invisible until you try it.

Asking an agent for this

The pattern from the last piece holds. A vague ask gets you the div.

Vague:

Add a dropdown menu to the header.

Specific:

Build the menu with a real button for the trigger and a ul of li for the items. Trigger gets aria-expanded and aria-controls. Open on click and on Enter or Space. Escape closes it and returns focus to the trigger. Arrow keys move between items. Style focus with :focus-visible, never outline: none. No positive tabindex. Targets at least 24 by 24 pixels.

Six sentences, and every one of them is something I would otherwise have to find and fix. This is the same trade as before: knowing the names lets you ask for the thing directly, instead of approving what comes back and hoping.

And when it does come back, walk it with the tab key. It takes half a minute and it catches what nothing else will.

Next in this run: the cascade and specificity, why the style you wrote is not the style that won.