Keyboard accessibility is the single highest-leverage thing you can fix on most sites. People who use screen readers, switch devices, sip-and-puff controllers, or simply have a broken trackpad all drive your interface with Tab, Shift+Tab, Enter, Space, and the arrow keys. If a control cannot be reached or operated that way, it does not exist for them. It also maps directly to the WCAG 2.2 success criteria under the Operable principle, which is the baseline referenced by EN 301 549 and the European Accessibility Act.
The good news: most keyboard failures come from a handful of recurring mistakes, and each has a concrete fix. This guide walks through focus order, visible focus, skip links, keyboard traps, and custom widgets, then shows you how to test the whole thing in about ten minutes with no specialised tooling.
Start with native elements and a sane focus order
The cheapest route to keyboard accessibility is using the right HTML element. A real <button>, <a href>, <input>, or <select> is focusable, operable, and announced correctly for free. The moment you write <div onclick> you inherit the browser's whole job: you must add tabindex="0", wire up both Enter and Space, and expose a role. Avoid that unless you genuinely have to.
Focus order (WCAG 2.4.3, Level A) must follow the meaning of the page, not its visual position. The browser derives tab order from DOM order, so the rule is simple: keep DOM order matching reading order. When you rearrange things visually with Flexbox order or grid placement, the tab sequence does not move with them. A sighted keyboard user then watches focus jump from the header to the footer to the middle of the page. Reorder the markup, not just the pixels.
One more rule: never use positive tabindex values. tabindex="5" does not mean "focus me fifth" in a friendly way; it yanks the element ahead of every natural element on the page and creates a tab order nobody can predict. Use only tabindex="0" (join the natural order) and tabindex="-1" (focusable by script, skipped by Tab).
Make focus visible, always
A keyboard user needs to see where they are. WCAG 2.4.7 Focus Visible (Level AA) requires a visible focus indicator, and WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum) (Level AA), meaning the focused element must not be fully hidden behind a sticky header or cookie banner. The classic mistake is the blanket reset:
*:focus { outline: none; } removes the indicator everywhere and is one of the most common audit failures we see.
The modern fix is :focus-visible, which lets the browser show a ring for keyboard interaction while suppressing it for mouse clicks. Style it deliberately:
:focus-visible { outline: 2px solid #1a5fff; outline-offset: 2px; }
Give the ring real contrast. The indicator must have a 3:1 contrast ratio against adjacent colours (the same threshold WCAG 1.4.11 sets for UI components), so a faint grey outline on a white card will not pass. Prefer outline over box-shadow for the ring, because outline is not clipped by overflow: hidden on a parent.
Add a skip link
If your header has a logo, six nav items, and a search box, a keyboard user has to Tab through all of them on every single page before reaching the content. A skip link fixes this and satisfies WCAG 2.4.1 Bypass Blocks (Level A). It is one of the fastest wins available.
Put it first in the DOM, point it at your main landmark, and let it appear on focus:
- Markup:
<a href="#main" class="skip-link">Skip to content</a>as the first focusable element in<body>. - Target:
<main id="main" tabindex="-1">so focus actually lands there when activated. - CSS: position it off-screen, then bring it on-screen with
.skip-link:focus { ... }so it is invisible to mouse users but appears for keyboard users.
The tabindex="-1" on <main> matters. Without it, some browsers move the document position but not keyboard focus, so the next Tab sends the user back to the top of the page.
Eliminate keyboard traps
A keyboard trap (WCAG 2.1.2 No Keyboard Trap, Level A) is any place focus can enter but not leave with the keyboard alone. Custom modals are the usual culprit: focus moves into the dialog, then Tab keeps cycling through the page behind it, or worse, gets stuck on an element with no way out but the mouse.
For a dialog, do four things: move focus into the dialog when it opens, trap focus inside it while open (Tab from the last element wraps to the first), close it on Escape, and return focus to the element that opened it. The native <dialog> element with showModal() handles most of this for you, including the focus trap and the inert backdrop, which is why it is now the recommended starting point.
Watch for accidental traps too: an embedded third-party widget, a custom date picker, or a rich text editor that swallows Tab. If your component intercepts Tab for navigation, you must provide a documented way out, such as Escape or Ctrl+M.
Custom widgets: the ARIA Authoring Practices contract
When you must build a custom widget (tabs, a combobox, a tree, a menu), you are signing up to reimplement keyboard behaviour that browsers give native controls for free. The reference is the WAI-ARIA Authoring Practices Guide (APG), which specifies the expected keys for each pattern. Follow it rather than inventing your own.
A few load-bearing conventions developers routinely miss:
- Roving tabindex. In a composite widget like a tab list or a radio group, the group is one Tab stop. You Tab in, then move between options with the arrow keys, not Tab. Implement this by keeping
tabindex="0"on the active item andtabindex="-1"on the rest, moving it as the user arrows around. - Buttons answer to both keys. A real
<button>fires on Enter and Space. A<div role="button">does not, so you must handle bothkeydownevents yourself, and callpreventDefault()on Space so the page does not scroll. - State lives in ARIA. A custom toggle needs
aria-pressed, a disclosure needsaria-expanded, a selected tab needsaria-selected. Keyboard operability and screen reader announcement are two halves of the same job; doing one without the other still fails.
If a pattern feels too involved to get right, that is a strong signal to fall back to native HTML. A <details>/<summary> disclosure or a styled <select> will be more robust than a hand-rolled equivalent.
How to test with the keyboard
You do not need a screen reader to catch most keyboard issues. Put the mouse down and run this pass on every key page:
- Press Tab from the top. Every interactive control should receive focus, in an order that matches the visual reading order, with a clearly visible indicator at each stop.
- Confirm you never get stuck. Tab all the way to the end and Shift+Tab all the way back. If focus ever traps or disappears, you have a bug.
- Operate each control with the keyboard. Enter activates links and buttons; Space toggles buttons and checkboxes; arrow keys move within radio groups, tabs, menus, and sliders.
- Open every modal, menu, and dropdown. Check that focus moves in, Escape closes it, and focus returns to the trigger.
- Watch for invisible focus. If the ring vanishes, focus may have landed on a hidden or off-screen element, often a menu that is visually closed but still in the tab order. Hidden content should be removed from focus order with
hidden,display: none, orinert.
Automated tools catch only part of this. A scanner can flag a missing aria-expanded or a zero-contrast outline, but it cannot tell whether Escape closes your menu or whether tab order makes sense. Use both. Run a free pass with the AccessScan scanner to surface the machine-detectable issues, then do the manual Tab walkthrough above for everything a tool cannot see. For the surrounding criteria, see our accessibility checklist.