Close
Keyboard Navigation WCAG Guide: Complete Tutorial for Developers

Keyboard Navigation: A Developer’s Guide to WCAG Compliance

Guide #2 in the NGI Accessibility Series | Skill level: Intermediate | Time: 20-30 minutes

What this guide covers: Keyboard navigation is how many users interact with the web — people using screen readers, those with motor impairments, power users, and anyone with a broken mouse. Our audit of 78 NGI-funded projects uncovered 2,410 keyboard navigation violations across 77 of those 78 projects. That is a near-universal failure rate.

This guide walks through the most common keyboard problems we found and gives you production-ready code to fix each one. Every fix targets WCAG 2.1.1 Keyboard (Level A), plus the related criteria 2.4.3 Focus Order and 2.1.2 No Keyboard Trap.

Quick Reference: Keyboard Interactions

Before fixing anything, you need to know which keys do what. This table covers the expected keyboard behaviour for common UI patterns. If your components do not respond to these keys, they have a keyboard accessibility problem.

Key Expected Behaviour Context
Tab Move focus to the next interactive element Global navigation
Shift + Tab Move focus to the previous interactive element Global navigation
Enter Activate a link or button Links, buttons, form submissions
Space Activate a button, toggle a checkbox Buttons, checkboxes, radio buttons
Arrow Keys Navigate within a composite widget Tabs, menus, radio groups, sliders
Escape Close or dismiss the current overlay Modals, dropdowns, tooltips
Home / End Jump to first/last item in a list Menus, tabs, listboxes

Common Problems Found in Our Audits

These are the patterns we saw most often across the 77 failing projects. Each section shows the broken version, explains why it fails, and provides the fix.

1. Elements Not Reachable via Tab

The single biggest offender. Developers build clickable elements from <div> or <span> tags, and these are invisible to the keyboard. A mouse user clicks them fine. A keyboard user cannot reach them at all.

✗ Inaccessible — div with click handler

<div class="card" onclick="openProject()">
  View Project Details
</div>

This <div> never receives focus. Keyboard users cannot interact with it.

✓ Accessible — use a real button

<button class="card" onclick="openProject()">
  View Project Details
</button>

A <button> is focusable and responds to Enter and Space out of the box.

✓ Alternative — if you must use a div

<div class="card"
     role="button"
     tabindex="0"
     onclick="openProject()"
     onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openProject()}">
  View Project Details
</div>

This adds tabindex="0", role="button", and a keydown handler. But it is more code and more risk. Use a real <button> when you can.

2. Custom Components Missing Keyboard Support

Custom sliders, toggles, accordions, and star ratings built from scratch almost always lack keyboard support. The WAI-ARIA Authoring Practices define the expected keyboard patterns for every widget type.

✗ Custom toggle with no keyboard support

<div class="toggle" onclick="toggleSetting(this)">
  <div class="toggle-knob"></div>
</div>

✓ Accessible toggle using role=”switch” with aria-checked

<button class="toggle"
        role="switch"
        aria-checked="false"
        aria-label="Enable dark mode"
        onclick="toggleSetting(this)">
  <span class="toggle-knob"></span>
</button>

<script>
function toggleSetting(btn) {
  const isOn = btn.getAttribute('aria-checked') === 'true';
  btn.setAttribute('aria-checked', String(!isOn));
}
</script>

Using <button> means keyboard access is built in. The role="switch" and aria-checked attributes tell screen readers the current state.

3. Dropdown Menus Not Keyboard Accessible

Dropdown menus that open on hover but close when you try to tab into them. Or menus that open but do not support arrow key navigation. Both are WCAG failures. See Guide #1: Accessible Dropdown Menus for a deep treatment of this topic.

Common trap: A CSS-only :hover dropdown is never keyboard accessible. You always need JavaScript to handle Enter/Space to open, Arrow keys to navigate, and Escape to close.

4. Modals Without Focus Trapping

When a modal opens and the user presses Tab, focus should stay within the modal. If it escapes behind the overlay, the user is stuck navigating invisible content. This violates WCAG 2.1.2 No Keyboard Trap — or rather, it is the opposite problem: focus is not trapped enough inside the modal, so the user gets lost.

✗ Modal opens but focus is not managed

<div class="modal" id="myModal">
  <div class="modal-content">
    <h2>Subscribe</h2>
    <input type="email" placeholder="Email">
    <button onclick="closeModal()">Close</button>
  </div>
</div>

<script>
function openModal() {
  document.getElementById('myModal').style.display = 'block';
  // No focus management at all
}
</script>

✓ Modal with proper focus trap (see full implementation below)

<div class="modal" id="myModal" role="dialog"
     aria-labelledby="modal-title" aria-modal="true">
  <div class="modal-content">
    <h2 id="modal-title">Subscribe</h2>
    <input type="email" placeholder="Email">
    <button onclick="closeModal()">Close</button>
  </div>
</div>

5. Missing Skip Links

Without a skip link, keyboard users must tab through the entire site header and navigation on every single page load before reaching the main content. On a site with 30 nav links, that is 30 tab presses wasted.

✗ No skip link — keyboard users tab through entire nav

<body>
  <header>
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
      <!-- 20 more links... -->
    </nav>
  </header>
  <main>...</main>
</body>

✓ Skip link as first focusable element

<body>
  <a href="#main-content" class="skip-link">
    Skip to main content
  </a>
  <header>
    <nav>...</nav>
  </header>
  <main id="main-content" tabindex="-1">
    ...
  </main>
</body>

Implementation Guide

A. Making Custom Buttons Keyboard Accessible

Whenever you absolutely cannot use a native <button>, here is the minimum you need to add to a <div> or <span>.

Custom Button with Full Keyboard Support

<!-- The element -->
<span class="custom-btn"
      role="button"
      tabindex="0"
      aria-label="Save document"
      id="save-btn">
  Save
</span>

<script>
const btn = document.getElementById('save-btn');

btn.addEventListener('click', handleSave);

btn.addEventListener('keydown', function(e) {
  // Buttons must respond to Enter and Space
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault(); // Prevent page scroll on Space
    handleSave();
  }
});

function handleSave() {
  console.log('Document saved');
}
</script>
Best practice: Use <button> whenever possible. It handles Enter, Space, focus, and form submission natively. The code above is a fallback for cases where you are stuck with a non-semantic element.

B. Tab Order Management

The tabindex attribute controls whether and how an element participates in keyboard navigation. There are three values you should know:

Value Meaning When to Use
tabindex="0" Element is focusable in the normal tab order Custom interactive elements that need keyboard access
tabindex="-1" Element is focusable via JavaScript but not via Tab Elements you focus programmatically (modal targets, skip link targets)
tabindex="1+" Element jumps ahead in tab order Never. This breaks the natural reading order and causes confusion.
Positive tabindex values are harmful. Using tabindex="5" or tabindex="100" forces that element to receive focus before everything else, regardless of its position in the DOM. This creates an unpredictable tab order that frustrates users. We found this pattern in multiple audited projects.

✗ Positive tabindex values — broken tab order

<input tabindex="3" placeholder="Last name">
<input tabindex="1" placeholder="First name">
<input tabindex="2" placeholder="Email">

Focus order: First name, Email, Last name. Confusing and inaccessible.

✓ Natural DOM order — no tabindex needed for native elements

<input placeholder="First name">
<input placeholder="Last name">
<input placeholder="Email">

Focus follows the source order. Predictable and correct.

C. Skip Links Implementation

A skip link is visually hidden until focused, then appears prominently. It must be the first focusable element on the page.

Production-Ready Skip Link

Why tabindex="-1" on <main>? The <main> element is not natively focusable. Adding tabindex="-1" allows the skip link to move focus there programmatically, without adding it to the normal tab sequence.

D. Focus Trapping for Modals

A focus trap keeps Tab cycling within the modal and lets Escape close it. When the modal closes, focus must return to the element that opened it.

Reusable Focus Trap Module

<!-- Modal markup -->
<div id="modal-overlay" class="modal-overlay" hidden>
  <div role="dialog" aria-labelledby="dlg-title" aria-modal="true"
       class="modal-dialog">
    <h2 id="dlg-title">Confirm Action</h2>
    <p>Are you sure you want to proceed?</p>
    <button id="modal-confirm">Confirm</button>
    <button id="modal-cancel">Cancel</button>
  </div>
</div>

<button id="open-modal-btn">Open Modal</button>

<script>
function trapFocus(element) {
  const focusableSelectors = [
    'a[href]', 'button:not([disabled])', 'input:not([disabled])',
    'select:not([disabled])', 'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])'
  ];
  const focusableEls = element.querySelectorAll(focusableSelectors.join(','));
  const firstEl = focusableEls[0];
  const lastEl = focusableEls[focusableEls.length - 1];

  function handleKeydown(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        // Shift+Tab: if on first element, wrap to last
        if (document.activeElement === firstEl) {
          e.preventDefault();
          lastEl.focus();
        }
      } else {
        // Tab: if on last element, wrap to first
        if (document.activeElement === lastEl) {
          e.preventDefault();
          firstEl.focus();
        }
      }
    }

    if (e.key === 'Escape') {
      closeModal();
    }
  }

  element.addEventListener('keydown', handleKeydown);

  // Focus the first focusable element
  firstEl.focus();

  // Return cleanup function
  return function() {
    element.removeEventListener('keydown', handleKeydown);
  };
}

let cleanupTrap = null;
let previouslyFocused = null;

function openModal() {
  const overlay = document.getElementById('modal-overlay');
  previouslyFocused = document.activeElement;
  overlay.hidden = false;
  cleanupTrap = trapFocus(overlay.querySelector('[role="dialog"]'));
}

function closeModal() {
  const overlay = document.getElementById('modal-overlay');
  overlay.hidden = true;
  if (cleanupTrap) cleanupTrap();
  if (previouslyFocused) previouslyFocused.focus(); // Return focus
}

document.getElementById('open-modal-btn').addEventListener('click', openModal);
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-confirm').addEventListener('click', function() {
  // Perform action, then close
  closeModal();
});
</script>
Key details that matter:
  • Store the element that opened the modal (previouslyFocused) and return focus to it on close.
  • Use aria-modal="true" so screen readers know content behind the modal is inert.
  • The cleanup function removes the event listener to avoid memory leaks.

E. Arrow Key Navigation for Menus and Tabs

For composite widgets (components made up of multiple related interactive elements, like tab lists, menu bars, and radio groups), the roving tabindex pattern is the standard approach. The idea: only one item in the group has tabindex="0" (the active one); all others have tabindex="-1". As the user presses arrow keys, the tabindex="0" value moves — or “roves” — to the newly focused item.

Roving Tabindex for a Tab Widget

<div class="tabs">
  <div role="tablist" aria-label="Project info">
    <button role="tab" id="tab-1" aria-selected="true"
            aria-controls="panel-1" tabindex="0">
      Overview
    </button>
    <button role="tab" id="tab-2" aria-selected="false"
            aria-controls="panel-2" tabindex="-1">
      Details
    </button>
    <button role="tab" id="tab-3" aria-selected="false"
            aria-controls="panel-3" tabindex="-1">
      Contact
    </button>
  </div>

  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    <p>Overview content here.</p>
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    <p>Details content here.</p>
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
    <p>Contact content here.</p>
  </div>
</div>

<script>
function initTabs(tablistElement) {
  const tabs = Array.from(tablistElement.querySelectorAll('[role="tab"]'));

  tablistElement.addEventListener('keydown', function(e) {
    const currentIndex = tabs.indexOf(document.activeElement);
    if (currentIndex === -1) return;

    let newIndex;
    switch (e.key) {
      case 'ArrowRight':
        newIndex = (currentIndex + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return; // Let other keys pass through
    }

    e.preventDefault();
    activateTab(tabs, newIndex);
  });

  tabs.forEach(function(tab, i) {
    tab.addEventListener('click', function() {
      activateTab(tabs, i);
    });
  });
}

function activateTab(tabs, index) {
  tabs.forEach(function(tab, i) {
    const isSelected = i === index;
    tab.setAttribute('aria-selected', String(isSelected));
    tab.setAttribute('tabindex', isSelected ? '0' : '-1');

    const panelId = tab.getAttribute('aria-controls');
    const panel = document.getElementById(panelId);
    if (panel) {
      panel.hidden = !isSelected;
    }
  });

  tabs[index].focus();
}

// Initialize
const tablist = document.querySelector('[role="tablist"]');
if (tablist) initTabs(tablist);
</script>

Interactive Demo: Keyboard-Accessible Tab Widget

This is a working tab widget. Try using it with your keyboard only — no mouse. Tab to reach the tab list, then use Arrow Left / Arrow Right to switch tabs. Home and End jump to the first and last tab.

Live Demo — Tab to this area and use arrow keys

Keyboard Basics

Every interactive element on your page should be operable with a keyboard. This means links, buttons, form controls, and custom widgets all need to respond to standard key presses. Native HTML elements like <button> and <a> handle this automatically. Custom elements built from <div> or <span> require explicit keyboard event handlers.

Test this: press Tab to leave the tab list, then Shift+Tab to come back. Use Arrow Right to move to the next tab.

Testing Checklist

Unplug your mouse (or put it in a drawer) and work through this list. If you get stuck at any point during keyboard testing, you have found a violation.

Manual Keyboard Testing Steps

  • Press Tab repeatedly from the top of the page. Can you reach every interactive element (links, buttons, inputs, controls)?
  • Is there a visible focus indicator on every focused element? Can you always tell where you are?
  • Does the focus order match the visual layout? No unexpected jumps?
  • Can you activate all buttons with Enter and Space?
  • Can you follow all links with Enter?
  • Can you open and close dropdown menus with Enter/Space and Escape?
  • Do menus support Arrow key navigation between items?
  • Is there a skip link that appears on the first Tab press? Does it actually move focus past the navigation?
  • When a modal opens, does focus move into the modal?
  • Can you Tab through all modal controls without focus escaping behind the overlay?
  • Does Escape close the modal and return focus to the trigger element?
  • Do tab widgets support Arrow Left/Arrow Right navigation?
  • Are there any keyboard traps where you get stuck and cannot Tab away?
  • Can you operate custom widgets (sliders, date pickers, carousels) using only the keyboard?
  • Do form error messages get announced when they appear? Can you navigate to them?
Automated tools miss most keyboard issues. Tools like axe and WAVE can flag missing tabindex or ARIA attributes, but they cannot test whether a widget actually works with a keyboard. Manual testing is essential. Budget at least 15 minutes per page.

Browser Support

The keyboard patterns in this guide work across all modern browsers. The main difference is in focus styling defaults.

Feature Chrome Firefox Safari Edge
tabindex Full Full Full Full
:focus-visible 86+ 85+ 15.4+ 86+
inert attribute 102+ 112+ 15.5+ 102+
aria-modal Full Full Full Full
Keyboard events (keydown) Full Full Full Full
Note on inert: The inert attribute makes an element and all its children non-interactive and invisible to assistive technology. For modals, adding inert to the content behind the dialog is a more reliable alternative to aria-modal="true", which has inconsistent screen reader support (particularly in Safari + VoiceOver).
Note on Safari: By default, Safari on macOS does not cycle focus through all elements with Tab. Users must enable “Use Tab to highlight each item on a webpage” in Safari > Settings > Advanced. This is a user setting, not a bug in your code, but it is worth mentioning in user documentation.

WCAG References

Criterion Level Summary
2.1.1 Keyboard A All functionality must be operable through a keyboard interface.
2.1.2 No Keyboard Trap A If focus can be moved to a component via keyboard, it must also be movable away.
2.4.3 Focus Order A Focusable components receive focus in an order that preserves meaning and operability.
2.4.7 Focus Visible AA Any keyboard operable UI has a visible focus indicator.
2.4.11 Focus Not Obscured AA Focused element is not entirely hidden by other content (WCAG 2.2).

Further Reading

Leave a Reply