Structure, keyboard, and focus

Make every action keyboard operable

Support standard Tab, Shift+Tab, Enter, Space, arrow, and Escape behavior appropriate to each control.

A page is keyboard operable when every action a mouse can perform has a key that does the same thing. That’s WCAG 2.1.1 Keyboard, Level A, and it’s the most useful test you can run because it needs no extra software.

But “a key” isn’t any key. Each kind of control has an expected key model, learned over decades. Break the model and the control might be reachable yet still unusable.

The keys people expect

Here is the model I test against on every page:

  • Tab and Shift + Tab move between controls
  • Enter activates links and buttons
  • Space activates buttons and toggles checkboxes
  • Arrow keys move inside a group: radios, selects, tabs, menus
  • Escape closes whatever just opened

Native elements implement all of this. On the registration page, buttons activate with Enter and Space, the “Privacy policy” link activates with Enter, and the contact preference radios move with arrows. That’s not code we wrote. It’s what button, a and input type="radio" do.

The invented date picker

The flawed page has one custom widget: a date picker for the arrival day, built from div elements. It opens when you press D, and you pick a day by typing its number. Nobody knows that, and no standard says D means date.

Replace it with the native control:

<label for="arrival">Arrival day</label>
<input type="date" id="arrival" name="arrival" min="2026-10-14" max="2026-10-15">

Now it opens with Space or Enter, the segments move with arrows, and the browser announces each part. The platform documents the behavior, not us.

Log every keystroke

To record the keyboard path through the form, I paste a small logger in the console. It prints the key and the element that had focus when it was pressed:

document.addEventListener('keydown', event => {
  const el = document.activeElement
  console.log(event.key, el.tagName, el.id || el.textContent.trim().slice(0, 20))
})

Then I complete the registration with the keyboard only. On the repaired page the log reads:

Tab INPUT fullname
Tab INPUT email
Tab INPUT pref-email
ArrowDown INPUT pref-phone
Tab SELECT ticket
Tab INPUT arrival
Tab BUTTON Register
Enter BUTTON Register

Eight keystrokes, no surprises. On the flawed page the same task needed sixteen: the Register div was unreachable and the date picker swallowed Tab once you were inside it, which is also a failure of 2.1.2 No Keyboard Trap.

The shortcut to avoid

Be careful with the “fix” of adding a keydown handler to a generic element. Without focusability and a role, the handler never fires for keyboard users, since the element never has focus. With them, you’re on the hook for the whole key model. Use the native control first. Add custom keys only for a widget HTML doesn’t have, following the published pattern, which we cover in the ARIA module.

Try this on your own form: run the logger, complete the task with the keyboard, and count the keystrokes that did nothing or did something unexpected. Each one is a finding.

Lesson completed