UXR SEO Analyzer documentation

Detailed guide

Keyboard Navigation Implementation Guide

View contents

Introduction

Keyboard navigation is a Level A requirement in WCAG 2.2, which makes it the accessibility floor rather than the ceiling. Criteria 2.1.1 (Keyboard) and 2.1.2 (No Keyboard Trap) state that every piece of functionality on a page must be operable by keyboard, and that a user must never get stuck inside a component with no way out.

This affects more people than is usually assumed. Screen reader users navigate by keyboard exclusively. People with motor disabilities using alternative input devices — switches, head pointers — generate keyboard events, not mouse events. Power users prefer keyboard shortcuts for speed. WebAIM puts keyboard accessibility problems among the five most common errors found in website audits.

This guide covers the core implementation patterns: tabindex management, keyboard event handling, the roving tabindex pattern for composite widgets, focus traps for modals, and skip links.

Focus management fundamentals

The tabindex attribute

The tabindex attribute controls how elements take part in Tab navigation. It has three functional values, and their behaviour differs sharply:

  • tabindex="0": The element joins the natural tab order, determined by its position in the DOM. This is the right value for making a custom element focusable, such as a <div> with role="button".
  • tabindex="-1": The element can receive focus programmatically, via element.focus(), but does not appear in the Tab flow. Useful for dialog containers and skip link destinations.
  • tabindex="1+" (positive values): The element jumps to the front of the tab order. It looks useful and causes serious problems: the Tab flow stops following the page's visual order, which disorients users. The W3C explicitly recommends against positive values.

Interactive HTML elements (<a href>, <button>, <input>, <select>, <textarea>) are focusable natively, with no tabindex needed. That is the main argument for using native elements instead of building custom controls out of <div>: the keyboard behaviour is already there.

Focus order and visual order

Tab order follows DOM order, not the visual layout on screen. If you use CSS flex-direction: column-reverse or order to rearrange elements visually, Tab will still walk them in the order they appear in the HTML. That mismatch between what the user sees and what the keyboard does is a frequent source of confusion.

The preferred fix is to make DOM order match visual order. Where that is not possible, document the behaviour and confirm the tab sequence still reads as logical even though it differs from the visual layout.

Keyboard event handlers

When you build custom components that do not use native HTML elements, you have to implement by hand the keyboard events the browser would have provided. The basic pattern captures keydown and runs the matching action for the key pressed.

function handleKeyboard(element, actions) {
  element.addEventListener('keydown', (e) => {
    const handlers = {
      'Enter': actions.activate,
      ' ': actions.activate,
      'ArrowUp': actions.previous,
      'ArrowDown': actions.next,
      'Home': actions.first,
      'End': actions.last,
      'Escape': actions.cancel
    };
    const handler = handlers[e.key];
    if (handler) {
      e.preventDefault();
      handler(e);
    }
  });
}

Two details matter here. First, e.preventDefault() stops the browser running the key's default behaviour — the space bar scrolling the page, for instance. Second, it reads e.key rather than e.keyCode, since keyCode is deprecated and key gives readable values such as 'Enter' or 'ArrowDown'.

For a <div> acting as a button, the minimum implementation needs both Enter and Space to trigger the action, and the element to carry role="button" and tabindex="0":

<div role="button" tabindex="0" onclick="handleClick()">Action</div>
element.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    element.click();
  }
});

The standing recommendation, though, is to reach for a native <button>, which brings all of this behaviour with no extra code.

Roving tabindex for composite widgets

Composite widgets such as toolbars, tab lists and menus need particular behaviour: only one element in the group is reachable by Tab, and the arrow keys move focus between the elements inside. The pattern is called "roving tabindex" because tabindex="0" moves between elements as the user navigates.

The idea is that the whole widget occupies a single Tab stop in the page flow. A toolbar with ten buttons should not take ten Tab presses to cross. Instead, Tab reaches the widget, the arrows navigate inside it, and the next Tab leaves for the next component on the page.

The implementation gives tabindex="0" to the active element and tabindex="-1" to the rest. When the user presses an arrow, the tabindex swaps and focus moves to the new element:

// On init: only the first element gets tabindex="0"
items.forEach((item, i) => {
  item.setAttribute('tabindex', i === 0 ? '0' : '-1');
});

// On arrow navigation:
function moveFocus(fromIndex, toIndex) {
  items[fromIndex].setAttribute('tabindex', '-1');
  items[toIndex].setAttribute('tabindex', '0');
  items[toIndex].focus();
}

Home and End should jump to the first and last element respectively. The arrows should wrap: from the last element, ArrowDown returns to the first.

Focus trap for modal dialogs

Criterion 2.1.2 forbids keyboard traps, but modal dialogs are the legitimate exception: while a modal is open, focus must stay inside it until the user closes it. That keeps the user from interacting by accident with content behind the modal that they cannot see.

The implementation has three phases. On opening, store the element that had focus, find every focusable element inside the modal, and move focus to the first of them. While the modal is open, when Tab reaches the last focusable element the next Tab returns to the first (and Shift+Tab from the first goes to the last). On closing, focus returns to the element that held it before.

function activateFocusTrap(modal) {
  const focusables = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusables[0];
  const last = focusables[focusables.length - 1];

  modal.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });

  first.focus();
}

Escape must always close the modal. Every keyboard user expects it, and it is documented in the WAI-ARIA Authoring Practices as a requirement of the dialog pattern.

Skip links are hidden links that appear when focused and let keyboard users jump straight to the main content without walking the entire navigation. They satisfy WCAG 2.4.1 (Bypass Blocks).

The implementation combines HTML, CSS and a little JavaScript. The link points at the id of the <main> element, stays off screen until focused, and the destination needs tabindex="-1" so it can take programmatic focus:

<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
.skip-link {
  position: absolute;
  top: -100%;
  left: 50%;
  transform: translateX(-50%);
  padding: 1rem 2rem;
  background: #000;
  color: #fff;
  z-index: 9999;
}
.skip-link:focus { top: 0; }

If the site has several important regions — main content, search, secondary navigation — you can offer more than one skip link. Each needs text that describes its destination clearly.

Key reference by component

Each kind of widget has keyboard conventions documented in the WAI-ARIA Authoring Practices that assistive technology users expect to find:

Component Required keys Behaviour
Button Enter, Space Triggers the button's action
Link Enter Navigates to the link destination
Dropdown menu Arrows, Enter, Escape Arrows navigate, Enter selects, Escape closes
Tabs Left/right arrows, Enter Arrows change the active tab
Modal dialog Tab (internal cycle), Escape Tab cycles inside the modal, Escape closes
Listbox Arrows, Home, End Arrows move through options, Home/End jump to the ends
Accordion Enter, Space Expands or collapses the section

Checklist

  • [ ] Every interactive element is reachable by keyboard
  • [ ] Tab order follows the visual reading order
  • [ ] No positive tabindex values are used
  • [ ] Composite widgets implement roving tabindex
  • [ ] Enter and Space activate buttons and controls
  • [ ] Arrow keys navigate inside composite widgets
  • [ ] Escape closes modals, menus and popups
  • [ ] No keyboard traps exist (open modals aside)
  • [ ] Skip links let users bypass repeated navigation
  • [ ] Focus is restored when modals and menus close
  • [ ] Focus indicators are visible and have enough contrast

Frequently asked questions

Why should I avoid positive tabindex values?

Positive tabindex values (1, 2, 3…) place the element ahead of every element with tabindex="0" in the tab order, regardless of DOM position. That produces a Tab flow that does not match the page's visual order, which disorients users. The W3C explicitly recommends avoiding them and organising the DOM so the natural order is the right one.

What is the difference between roving tabindex and aria-activedescendant?

Both patterns solve the same problem — navigation inside composite widgets — in different ways. Roving tabindex moves real DOM focus between elements, changing tabindex values as it goes. aria-activedescendant keeps focus on the container and uses an ARIA attribute to say which child is "active". Roving tabindex has better screen reader support and is the approach the WAI-ARIA Authoring Practices recommend for most components.

Do I have to implement every keyboard shortcut in the WAI-ARIA Authoring Practices?

The keys documented there split into required and optional. The required ones — Enter, Space, arrows, Escape, depending on the component — must always be implemented. The optional ones, such as type-ahead to select by character in a list, improve the experience but are not needed for WCAG conformance.

How do I check that no keyboard traps exist?

The most direct test is to navigate the whole page using only Tab and Shift+Tab. If at any point you can move neither forward nor back, there is a trap. Pay particular attention to modals, iframes, video players, rich text editors and third-party components, which are the most common sources.


References

  1. W3C - WCAG 2.2 SC 2.1.1 Keyboard
  2. W3C - WCAG 2.2 SC 2.1.2 No Keyboard Trap
  3. W3C - ARIA Authoring Practices - Keyboard
  4. WebAIM - Keyboard Accessibility
  5. MDN - Keyboard-navigable JavaScript widgets

Related articles

In the same category

Introduction

AI Crawlability

As AI assistants like ChatGPT, Claude, Gemini, and Perplexity become primary information sources, a new question emerges: Should you allow AI bots to access...

Detailed guide

AI Crawler Management Guide

Managing AI crawler access requires understanding the diverse landscape of AI bots, their purposes, and the technical mechanisms to control them

Introduction

Alt Text for Screen Readers

Alt text (alternative text) provides a text description of images for users who cannot see them

Introduction

Alt Text for SEO: How to Write It by Image Type

Every image on your website is either helping or hurting your SEO

Detailed guide

Alt Text Optimization: Keywords, Automation and Auditing

Alt text optimization sits at the intersection of SEO, accessibility, and user experience

Introduction

ARIA Labels

ARIA labels provide accessible names for elements that screen readers announce to users

Last updated: