Implementation Guide

Keyboard Accessibility

Updated March 2026Reviewed by Khushwant Parihar, CPACC

Every feature on a page has to be reachable and operable with a keyboard alone, and that one requirement decides how you build focus order, skip links, custom widgets, and modals. This guide covers focus management, roving tabindex, keyboard traps, and focus trapping, with interactive demos and copy-ready code mapped to WCAG 2.2.

Introduction

Keyboard accessibility ensures that every feature on your website can be reached and operated using only a keyboard -- no mouse or touch input required. According to the CDC, approximately 26% of adults in the United States have some form of disability, and roughly 7.5% have a motor disability that affects their ability to use a pointing device. These numbers represent tens of millions of people who may depend entirely on keyboard navigation to interact with the web.

The reach of keyboard accessibility extends far beyond users with permanent disabilities. Power users prefer keyboard shortcuts for speed and efficiency. Screen reader users rely on the keyboard as their primary navigation interface since screen readers are inherently keyboard-driven tools. People with temporary injuries -- a broken arm, recovering from surgery, or dealing with repetitive strain injury -- also depend on keyboard access. By building keyboard-accessible interfaces, you serve all of these groups simultaneously.

The Web Content Accessibility Guidelines (WCAG) encode these requirements into specific, testable success criteria. The four most directly relevant are: 2.1.1 Keyboard (all functionality must be operable via keyboard), 2.1.2 No Keyboard Trap (focus can always be moved away from any component), 2.4.3 Focus Order (navigation sequence must be logical and meaningful), and 2.4.7 Focus Visible (the keyboard focus indicator must always be clearly visible). This guide covers all four in depth, with interactive demos and production-ready code examples.

Level A

2.1.1

Keyboard

Level A

2.1.2

No Keyboard Trap

Level A

2.4.3

Focus Order

Level AA

2.4.7

Focus Visible

Fundamentals

Tab Order

When a user presses the Tab key, the browser moves focus to the next focusable element in the DOM order -- the order elements appear in your HTML source. This is why your source order should match your visual layout. Using CSS to visually rearrange elements (via flexbox order, grid positioning, or absolute positioning) without matching the DOM order creates a disorienting experience for keyboard users.

The tabindex attribute controls how elements participate in the tab sequence. Use tabindex="0" to add a non-interactive element to the natural tab order. Use tabindex="-1" to make an element focusable via JavaScript but not via Tab. Never use positive values like tabindex="5" -- they override the natural DOM order and create an unpredictable, maintenance-nightmare tab sequence.

Never use positive tabindex values. They override the natural tab order and make maintenance extremely difficult. Stick to 0 and -1.

Native vs Custom Elements

Semantic HTML elements like <button>, <a href>, and <input> are keyboard-accessible by default. A button can be activated with Enter or Space. A link can be followed with Enter. Form inputs accept keyboard input naturally. When you use a <div> or <span> as an interactive element, you lose all built-in behavior and must manually add tabindex, role, onKeyDown, and onClick handlers. Always prefer native elements when a suitable one exists.

Focus Indicators

WCAG 2.4.7 requires that the keyboard focus indicator is visible at all times. The modern approach is to use :focus-visible which only shows the focus ring for keyboard navigation (not mouse clicks), giving you the best of both worlds. Never remove the browser default outline without providing a high-contrast replacement.

✓ Accessible
focus-indicators.css
:focus-visible {
  outline: 3px solid #3b82f6;
  outline-offset: 2px;
  border-radius: 4px;
}
✗ Inaccessible
never-do-this.css
*:focus {
  outline: none; /* NEVER do this without a replacement! */
}

Focus Management

In static HTML pages, focus management happens naturally -- the user tabs through elements in sequence. But modern web applications are dynamic: content loads asynchronously, pages change without full reloads, and elements appear and disappear in response to user actions. In these situations, you must programmatically move focus to keep keyboard users oriented.

When to Manage Focus

  • After SPA page navigation: Move focus to the new page heading or main content area.
  • After adding dynamic content: When a "Load more" button adds items, move focus to the first new item.
  • After removing content: Move focus to the next or previous sibling, never leave it on a removed element.
  • After form submission: Move focus to a success or error message so screen readers announce it immediately.
Interactive Demo

Click "Load 3 More Items" and notice that focus automatically moves to the first new item. This is focus management in action.

  • Item 1
  • Item 2
  • Item 3

After loading, focus moves to the first new item via a useRef + .focus() pattern.

✓ Accessible
FocusManagement.tsx
import { useRef, useEffect, useState } from 'react';

function DynamicList() {
  const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
  const [justAdded, setJustAdded] = useState(false);
  const newItemRef = useRef<HTMLLIElement>(null);

  // Move focus to the first new item after loading
  useEffect(() => {
    if (justAdded && newItemRef.current) {
      newItemRef.current.focus();
      setJustAdded(false);
    }
  }, [justAdded, items]);

  const loadMore = () => {
    const nextIndex = items.length + 1;
    setItems(prev => [
      ...prev,
      `Item ${nextIndex}`,
      `Item ${nextIndex + 1}`,
      `Item ${nextIndex + 2}`,
    ]);
    setJustAdded(true);
  };

  return (
    <div>
      <ul>
        {items.map((item, i) => (
          <li
            key={item}
            ref={i === items.length - 3 && justAdded ? newItemRef : null}
            tabIndex={i === items.length - 3 && justAdded ? -1 : undefined}
          >
            {item}
          </li>
        ))}
      </ul>
      <button onClick={loadMore}>Load more items</button>
    </div>
  );
}

Roving Tabindex

Consider a toolbar with 10 buttons. If every button were in the tab order, a user would need to press Tab 10 times to move past the toolbar. The roving tabindex pattern solves this: only one element in the group has tabindex="0", making it the single tab stop. All other elements have tabindex="-1". Arrow keys move focus (and the tabindex="0" designation) between items within the group.

This pattern is used in toolbars, tab lists, menu bars, tree views, and radio groups. The user Tabs into the widget, uses Arrow keys to navigate within it, and Tabs out to the next component. Home jumps to the first item, End jumps to the last. This drastically reduces the number of keystrokes required to navigate past composite widgets.

Interactive Demo

Tab into the toolbar. Use Left/Right Arrow keys to move between buttons. Tab again to exit. Home/End jump to first/last button.

Tab enter/exitArrow keys move between buttons

Active: Bold (tabindex="0"). All others have tabindex="-1".

✓ Accessible
RovingTabindex.tsx
import { useState, useRef, useEffect } from 'react';

function Toolbar() {
  const [activeIndex, setActiveIndex] = useState(0);
  const items = ['Bold', 'Italic', 'Underline', 'Link'];
  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);

  // Move DOM focus whenever activeIndex changes
  useEffect(() => {
    buttonRefs.current[activeIndex]?.focus();
  }, [activeIndex]);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        setActiveIndex((index + 1) % items.length);
        break;
      case 'ArrowLeft':
        e.preventDefault();
        setActiveIndex((index - 1 + items.length) % items.length);
        break;
      case 'Home':
        e.preventDefault();
        setActiveIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setActiveIndex(items.length - 1);
        break;
    }
  };

  return (
    <div role="toolbar" aria-label="Formatting options">
      {items.map((item, i) => (
        <button
          key={item}
          ref={(el) => { buttonRefs.current[i] = el; }}
          tabIndex={i === activeIndex ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, i)}
          onClick={() => setActiveIndex(i)}
          aria-pressed={i === activeIndex}
        >
          {item}
        </button>
      ))}
    </div>
  );
}

Keyboard Traps

A keyboard trap occurs when a keyboard user navigates into a component and cannot navigate out using the keyboard alone. The Tab key and Shift+Tab either do nothing or cycle within the same component indefinitely. Common causes include custom widgets that intercept Tab key events without allowing escape, embedded third-party iframes (ads, chat widgets, video players), JavaScript event listeners that call e.preventDefault() on all keydown events, and auto-focus loops where a blur handler immediately refocuses the same element.

WCAG 2.1.2 (No Keyboard Trap): If keyboard focus can be moved to a component using a keyboard interface, then focus must be movable away from that component using only a keyboard. If it requires more than standard keys (Tab, Shift+Tab, Arrow keys), the user must be advised of the method.
Interactive Demo

Switch between the "Bad" and "Good" tabs to compare. In the Bad tab, activating the trap prevents Tab from working. Press Escape to exit the trap.

Custom Widget (Trap Demo)

Click "Activate Trap" to simulate a keyboard trap.

Review the keyboard-trap examples and detection guidance in the keyboard traps section.
✗ Inaccessible
keyboard-trap-causes.ts
// BAD: This creates a keyboard trap!
element.addEventListener('keydown', (e) => {
  e.preventDefault(); // Prevents ALL keys including Tab
  // Handle your custom keys here
});

// BAD: Auto-focus loop
input.addEventListener('blur', () => {
  input.focus(); // User can never leave this input
});
✓ Accessible
keyboard-trap-prevention.ts
// GOOD: Only prevent default for the keys you handle
element.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
    e.preventDefault();
    handleArrowNavigation(e.key);
  }
  // Tab, Shift+Tab, Escape pass through naturally
});

// GOOD: Validate on blur without trapping
input.addEventListener('blur', () => {
  validateField(input); // Validate but do NOT refocus
});

Modal Focus Trapping

Modal dialogs are the single exception to the "no keyboard trap" rule. When a modal is open, focus should be trapped inside it because the background content is inert and not visible to the user. Without focus trapping, a keyboard user could Tab behind the modal into content they cannot see, which is even more disorienting than the trap itself.

Requirements for Accessible Modals

  1. Focus moves into the modal when it opens (to the modal container or first focusable element).
  2. Tab cycles within the modal only. From the last element, Tab wraps to the first. From the first, Shift+Tab wraps to the last.
  3. Escape closes the modal. This is the standard convention users expect.
  4. Focus returns to the trigger when the modal closes, so the user resumes where they left off.
  5. Background content is inert. Use aria-modal="true" and role="dialog".
Interactive Demo

Click 'Open Dialog' to open the modal. Try Tab (cycles within), Shift+Tab (wraps backward), and Escape (closes and returns focus to the button).

Focus is trapped inside the dialog. Escape closes it. Focus returns to this button on close.

✓ Accessible
AccessibleModal.tsx
import { useRef, useEffect } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<Element | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 1. Store the currently focused element
      previousFocusRef.current = document.activeElement;
      // 2. Move focus into the modal
      modalRef.current?.focus();
    } else if (previousFocusRef.current) {
      // 4. Return focus to the trigger on close
      (previousFocusRef.current as HTMLElement)?.focus();
      previousFocusRef.current = null;
    }
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // 3. Escape closes the modal
    if (e.key === 'Escape') {
      onClose();
      return;
    }

    // 2. Trap Tab within the modal
    if (e.key === 'Tab') {
      const focusable = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (!focusable?.length) return;

      const first = focusable[0] as HTMLElement;
      const last = focusable[focusable.length - 1] as HTMLElement;

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus(); // Wrap backward
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus(); // Wrap forward
      }
    }
  };

  if (!isOpen) return null;

  return (
    <>
      <div className="backdrop" onClick={onClose} aria-hidden="true" />
      <div
        role="dialog"
        aria-modal="true"
        aria-label="Modal title"
        ref={modalRef}
        tabIndex={-1}
        onKeyDown={handleKeyDown}
      >
        {children}
      </div>
    </>
  );
}

Custom Widgets

When building custom interactive widgets -- dropdown menus, tab panels, tree views, sliders, comboboxes -- you must implement the keyboard patterns that users expect based on the WAI-ARIA Authoring Practices Guide. These conventions are what assistive technology users learn and rely on. Deviating from them means your widget will be unpredictable and unusable for keyboard-only users.

The table below summarizes the expected keyboard interactions for common widget types. When building any custom widget, always consult the WAI-ARIA Authoring Practices Guide for the full specification.

WidgetKeysBehavior
ButtonEnter SpaceActivate
LinkEnterNavigate
CheckboxSpaceToggle
Radio GroupArrow keysSelect option
Tab PanelArrow keysSwitch tabs
MenuArrow keys Enter EscapeNavigate, select, close
DialogTab EscapeCycle focus, close
ComboboxArrow keys Enter EscapeNavigate, select, close
SliderArrow keysAdjust value

Testing Checklist

Use this checklist every time you build or audit a page. Disconnect your mouse, put it in a drawer, and navigate the entire page using only your keyboard. Mark each item as you verify it. You can download a PDF copy of this checklist using the button below.

  1. 1. Can you Tab to all interactive elements?
  2. 2. Is the tab order logical and follows visual layout?
  3. 3. Are focus indicators clearly visible?
  4. 4. Can you activate all controls with Enter/Space?
  5. 5. Can you navigate custom widgets with arrow keys?
  6. 6. Are there any keyboard traps?
  7. 7. Do modals trap and return focus correctly?
  8. 8. Do skip navigation links work?
  9. 9. Can you access all content without a mouse?
  10. 10. Does focus move correctly for dynamic content?

0 of 10 items completed

Frequently Asked Questions

Why is keyboard accessibility important?

Keyboard accessibility is essential because millions of users rely on keyboards as their primary or sole means of navigating the web. This includes people with motor disabilities who cannot use a mouse, blind users who navigate with screen readers (which are keyboard-driven), power users who prefer keyboard shortcuts for efficiency, and people with temporary injuries like a broken arm. WCAG Success Criterion 2.1.1 (Keyboard) requires that all functionality be operable through a keyboard interface. Without keyboard accessibility, you effectively lock out a significant portion of your users.

What is a keyboard trap?

A keyboard trap occurs when a user navigating with their keyboard becomes stuck inside a component and cannot Tab or Shift+Tab out of it. Common causes include poorly coded custom widgets, embedded iframes, and video or audio players that capture focus. WCAG Success Criterion 2.1.2 (No Keyboard Trap) specifically prohibits this. If keyboard focus can be moved to a component using a keyboard interface, it must be possible to move focus away from that component using only a keyboard. The sole exception is modal dialogs, which intentionally trap focus for usability, but must allow users to close them with Escape.

What is roving tabindex?

Roving tabindex is a keyboard navigation pattern used in composite widgets like toolbars, tab lists, menus, and tree views. Instead of making every item in a group tabbable (which would require many Tab presses to move through), only one item has tabindex="0" (making it tabbable), while all others have tabindex="-1" (removing them from the tab sequence). Arrow keys move focus and the tabindex="0" designation between items within the group. This means a user can Tab into the group, use Arrow keys to navigate within it, and Tab out to the next component, greatly reducing the number of keystrokes needed.

How do I test keyboard accessibility?

To test keyboard accessibility, disconnect or stop using your mouse and navigate your entire page using only the keyboard. Press Tab to move forward through interactive elements and Shift+Tab to move backward. Verify that: (1) you can reach every interactive element, (2) the focus order is logical and matches the visual layout, (3) focus indicators are clearly visible on every element, (4) all buttons and links can be activated with Enter or Space, (5) custom widgets support arrow keys where appropriate, (6) there are no keyboard traps, (7) modals trap and return focus correctly, and (8) skip links work. Additionally, use browser DevTools to inspect tabindex values and automated tools like axe, Lighthouse, or the Accessibility.build audit tool for comprehensive analysis.

What is a skip navigation link?

A skip navigation link (also called a "skip link" or "skip to main content" link) is a hidden anchor link placed at the very beginning of a page that becomes visible when it receives keyboard focus. When activated, it jumps the user past the site header, navigation menus, and other repetitive content directly to the main content area. This is required by WCAG Success Criterion 2.4.1 (Bypass Blocks) and is one of the simplest yet most impactful keyboard accessibility features you can implement. Without it, keyboard users must Tab through every navigation link on every page before reaching the content they want to read.

What WCAG criteria relate to keyboard accessibility?

Several WCAG criteria directly address keyboard accessibility. The most important are: 2.1.1 Keyboard (Level A) -- all functionality must be operable via keyboard; 2.1.2 No Keyboard Trap (Level A) -- users must be able to navigate away from any component; 2.1.4 Character Key Shortcuts (Level A) -- if single-character keyboard shortcuts exist, they must be remappable or disableable; 2.4.1 Bypass Blocks (Level A) -- a mechanism to skip repeated content blocks (skip links); 2.4.3 Focus Order (Level A) -- focus order must be logical and meaningful; 2.4.7 Focus Visible (Level AA) -- keyboard focus indicator must be visible; and 2.4.11 Focus Not Obscured (Minimum) (Level AA, new in WCAG 2.2) -- the focused item must not be entirely hidden by other content.

Essential Accessibility Resources

Comprehensive tools, checklists, and guides to help you create inclusive digital experiences

Top Pick

Focus Management Guide

tabindex, :focus-visible, focus traps, restoration, roving tabindex, skip links, and route-change focus, mapped to WCAG 2.2
focus management
tabindex
focus trap
+2 more
View guide
Top Pick

WCAG 2.4.7 Focus Visible Guide

Complete guide to visible keyboard focus indicators: why never to remove the outline, :focus-visible, contrast and thickness, forced-colors support, code examples, and testing
keyboard accessibility
focus management
View guide
Top Pick

WCAG 2.4.11 Focus Not Obscured (Minimum) Guide

Complete guide to keeping the keyboard-focused element visible: why sticky headers hide focus, the scroll-padding and scroll-margin fix, code examples, and testing
focus management
keyboard accessibility
View guide
Top Pick

WCAG 2.1.1 Keyboard Guide

All functionality is available from a keyboard interface.
wcag 2.1.1
keyboard
keyboard accessible
View guide
Top Pick

Skip Links & Bypass Blocks Guide

Build a skip link that actually works: the first-focusable HTML, the visually-hidden-until-focused CSS, and the number one bug where the page scrolls but keyboard focus never moves because the target is not focusable. Plus landmarks and headings as the real bypass for screen reader users, multiple skip links, skip links in single-page apps and React, and how to test bypass blocks, mapped to WCAG 2.4.1
skip links
skip navigation
View guide

WCAG 2.1.2 No Keyboard Trap Guide

Focus can be moved away from any component using standard keyboard methods.
no keyboard trap
keyboard accessible
View guide