Go back

Keyboard-First Experiences

This guide provides actionable technical standards for building keyboard-accessible web and desktop apps that work for everyone—from users with disabilities to power users who prefer keyboard-first experiences.

Quick Start

The impactful changes you can implement:

  1. Use semantic HTML, <button>, <input>, <a> instead of <div>.
  2. Never remove focus outlines without providing clear alternatives.
  3. Add skip links to bypass navigation.
  4. Label all form controls with <label> elements.
  5. Test with Tab key only, unplug your mouse.

Why Keyboard Accessibility Matters

Keyboard accessibility eliminates mouse dependency. It’s essential for users with motor impairments, limited mobility, or visual disabilities who navigate with keyboards and screen readers. Most assistive technologies emulate keyboard input, making this foundational to accessible design.

By prioritizing keyboard accessibility, you’ll solve accessibility issues for users with motor impairments, visual disabilities, and temporary injuries while meeting legal compliance requirements (WCAG, ADA), and also creating faster workflows for power users who prefer keyboard navigation.

Core Principles

An application is keyboard-accessible if it meets these four criteria:

PrincipleDescription
ReachableAll interactive elements must be reachable via the Tab key.
Visible FocusThe element that currently has focus must have a clear visual indicator.
Logical OrderThe tab order must follow a logical sequence, consistent with the visual layout.
OperableOnce focused, an element’s function must be triggerable with the keyboard (e.g., Enter, Space, Arrow Keys).

Technical Implementation

Use Semantic HTML

Using semantic HTML is the foundation of accessibility. Native interactive elements like <button>, <input>, <a>, and <select> are focusable by default (i.e., they have a tabindex of 0) and have built-in keyboard behaviors.

Avoid creating interactive components from non-semantic elements like <div> or <span>. While you can make a <div> clickable, it won’t be focusable by default and won’t be properly announced by screen readers without extra ARIA attributes.

Example: An inaccessible custom button

<!-- This is not focusable with a keyboard -->
<div class="burger">
  <span class="line"></span>
  <span class="line"></span>
  <span class="line"></span>
</div>

Solution: Use a <button>

<!-- This is accessible by default -->
<button class="burger">
  <span class="line"></span>
  <span class="line"></span>
  <span class="line"></span>
  <span class="visually-hidden">Menu</span>
</button>

By using a <button>, you get keyboard focus, Enter/Space activation, and proper screen reader announcements for free. You can use CSS to remove any default browser styling.

The tabindex Attribute

Control element focusability with the tabindex attribute.

ValueMeaningUsage Example
tabindex="0"Includes an element in the natural tab order.Custom interactive elements (e.g., a <div> as a button)
tabindex="-1"Makes an element focusable via JavaScript (element.focus()), but not in the tab order.Managing focus in dynamic components like modals
tabindex > 0Creates a separate tab order, overriding the natural DOM sequence. Not recommended.Avoid using positive values

Focus Indication

Never remove the browser’s default focus outline without providing a clear and high-contrast alternative. A visible focus indicator is essential for users to know where they are on the page. Use the :focus-visible pseudo-class to show focus styles only during keyboard navigation.

/* Example of a clear, on-brand focus style */
:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
  border-radius: 4px;
}

ARIA for Custom Components

ARIA (Accessible Rich Internet Applications) provides semantics for custom widgets when no native HTML element is suitable.

  • Rule: Prefer native HTML elements (<button>, <input>, <select>) whenever possible.
  • Use Case: When building custom components (e.g., a custom dropdown), use ARIA to define the element’s role (its purpose) and aria-* attributes (its state). ARIA does not provide keyboard behavior, you must implement that with JavaScript.

JavaScript Event Handling

When adding keyboard support to custom components, use device-independent event handlers.

  • Prefer click for activation: The click event is triggered by mouse clicks, Enter, and Space on buttons and links, making it more robust than mousedown or touchstart.
  • Use keydown for responsive actions: Use keydown for events that need to fire immediately, such as navigating items in a dropdown menu with arrow keys.
  • Use keyup for non-repeating actions: Use keyup for actions that should only fire once when a key is released, such as confirming a selection or closing a dialog. This prevents the event from firing multiple times if the user holds down the key.

Performance Note: Keyboard events can fire rapidly. Consider debouncing for expensive operations:

function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

Common Accessibility Patterns

  • Problem: Long navigation menus force users to tab through many links to reach the main content.
  • Solution: Implement a “skip link” as the first focusable element to allow users to bypass navigation blocks. The link is visually hidden until it receives focus.
<body>
  <a href="#content" class="skip-link">Skip to main content</a>
  <nav>
    <!-- Navigation links -->
  </nav>
  <main id="content" tabindex="-1">
    <!-- Content of the page -->
  </main>
</body>

The tabindex="-1" on <main> allows it to be programmatically focused by the skip link, without adding it to the natural tab order.

.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: white;
  padding: 8px;
  z-index: 100;
}

.skip-link:focus {
  top: 10px;
}

/* Ensure content is focusable for the link to work */
#content:focus {
  outline: none;
}

Roving tabindex

  • Problem: Components with many interactive children (e.g., a toolbar, a grid of images) create an excessive number of tab stops.
  • Proposed Solution: Use the roving tabindex pattern.
    1. Set tabindex="0" on the currently active item and tabindex="-1" on all others.
    2. The user tabs once to focus the component.
    3. Use JavaScript to listen for arrow keys to move focus between items, updating which element has tabindex="0".
    4. The Tab key moves focus out of the component.
<div id="toolbar" role="toolbar" aria-label="Text formatting">
  <button type="button" role="button" tabindex="0">Bold</button>
  <button type="button" role="button" tabindex="-1">Italic</button>
  <button type="button" role="button" tabindex="-1">Underline</button>
</div>
const toolbar = document.getElementById('toolbar');
const buttons = toolbar.querySelectorAll('button');

toolbar.addEventListener('keydown', (e) => {
  let currentIndex = Array.from(buttons).indexOf(document.activeElement);

  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
    e.preventDefault();
    buttons[currentIndex].setAttribute('tabindex', -1);

    if (e.key === 'ArrowRight') {
      currentIndex = (currentIndex + 1) % buttons.length;
    } else if (e.key === 'ArrowLeft') {
      currentIndex = (currentIndex - 1 + buttons.length) % buttons.length;
    }

    buttons[currentIndex].setAttribute('tabindex', 0);
    buttons[currentIndex].focus();
  }
});

Focus Trapping in Modals

  • Problem: A modal opens, but focus can escape to the page behind it.
  • Solution: When a modal is active, trap focus within it.
    1. When the modal opens, save a reference to the element that opened it and move focus to the first interactive element inside the modal.
    2. Listen for keydown events. If Tab is pressed, check if the focus is on the first or last element. If so, manually move focus to the last or first element, respectively, creating a cycle.
    3. The Escape key must close the modal.
    4. Upon closing, return focus to the element that opened the modal.
class FocusTrap {
  constructor(element) {
    this.element = element;
    this.focusableElements = element.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    this.firstFocusable = this.focusableElements[0];
    this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
  }

  trap(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === this.firstFocusable) {
          e.preventDefault();
          this.lastFocusable.focus();
        }
      } else {
        if (document.activeElement === this.lastFocusable) {
          e.preventDefault();
          this.firstFocusable.focus();
        }
      }
    }
    if (e.key === 'Escape') {
      this.close();
    }
  }
}

Note: For production use, consider libraries like focus-trap for vanilla JS or focus-trap-react for React.

A keyboard-accessible dropdown menu with ARIA roles and proper focus management.

<div class="dropdown">
  <button aria-haspopup="listbox" aria-expanded="false" id="dropdown-button">
    Choose option
  </button>
  <ul role="listbox" aria-labelledby="dropdown-button" hidden>
    <li role="option" tabindex="-1">Option 1</li>
    <li role="option" tabindex="-1">Option 2</li>
    <li role="option" tabindex="-1">Option 3</li>
  </ul>
</div>

Live Regions for Notifications

Use ARIA live regions to announce dynamic content changes.

function showNotification(message, type = 'polite') {
  const notification = document.createElement('div');
  notification.setAttribute('aria-live', type);
  notification.setAttribute('aria-atomic', 'true');
  notification.className = 'visually-hidden';
  notification.textContent = message;
  
  document.body.appendChild(notification);
  
  setTimeout(() => {
    document.body.removeChild(notification);
  }, 1000);
}

React Hook Example

A reusable hook for arrow key navigation and selection in custom lists or menus.

function useKeyboardNavigation(items, onSelect) {
  const [activeIndex, setActiveIndex] = useState(0);
  
  const handleKeyDown = useCallback((e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex((prev) => (prev + 1) % items.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
        break;
      case 'Enter':
        e.preventDefault();
        onSelect(items[activeIndex]);
        break;
    }
  }, [items, activeIndex, onSelect]);
  
  return { activeIndex, handleKeyDown };
}

Form Accessibility

Accessible forms are critical for user interaction. Ensure that every part of a form can be completed using only the keyboard.

  • Use Semantic HTML: Always use native form elements like <form>, <label>, <input>, <textarea>, <select>, and <button>. These elements have built-in keyboard accessibility and are understood by screen readers. Avoid creating “fake” form controls out of <div>s.
  • Label Everything: Every form control must have a corresponding <label> element, associated via the for attribute. This ensures that screen readers announce the purpose of each control when it receives focus.
  • Logical Order: The tab order must flow logically from one field to the next, matching the visual presentation. The default DOM order usually handles this correctly if the HTML is structured logically.
  • Error Handling: When a user submits a form with errors, programmatically move focus to the first invalid field. The error message for each field should be clearly visible and programmatically linked to its input using the aria-describedby attribute.
  • Submission: Users must be able to submit the form by pressing Enter on the submit button or, in many cases, by pressing Enter from within a text field.

Form Error Handling Example

A snippet that moves focus to the first invalid form field and uses ARIA live regions to announce errors.

function handleFormSubmit(e) {
  e.preventDefault();
  const form = e.target;
  const firstInvalidField = form.querySelector(':invalid');
  
  if (firstInvalidField) {
    firstInvalidField.focus();
    
    const errorMessage = firstInvalidField.getAttribute('aria-describedby');
    if (errorMessage) {
      document.getElementById(errorMessage).setAttribute('aria-live', 'assertive');
    }
  }
}

Standard Component Keyboard Interactions

Users expect standard widgets to behave predictably. The WAI-ARIA Authoring Practices provides the definitive guide for these patterns.

Tabs

KeyAction
Left/Right ArrowsSwitch between tabs.
TabMove focus away from the tab group.
Home/EndMove to the first/last tab.

Menus (Dropdowns)

KeyAction
Up/Down arrowsNavigate options.
Enter/ReturnSelect an option and close.
EscapeClose without selection.

Accordions

KeyAction
Up/Down arrowsMove between headers.
Enter/SpaceExpand or collapse the focused panel.
Home/EndMove to the first/last header.

Single-Page Apps (SPAs)

On route changes, screen readers need to be notified and focus must be managed.

  1. Update Title: Change document.title to reflect the new view.
  2. Manage Focus: Programmatically move focus to the main heading or container of the new content. This prevents the user’s focus from being reset to the top of the document.
  3. Announce Route Changes: Use an ARIA live region to announce the new page title to screen reader users.
<!-- Add this to your main App shell -->
<div class="visually-hidden" aria-live="polite" aria-atomic="true" id="route-announcer"></div>
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}
function handleRouteChange(newRoute) {
  // 1. Update document title
  document.title = `My App - ${newRoute.name}`;

  // 2. Announce to screen readers
  const announcer = document.getElementById('route-announcer');
  announcer.textContent = `Navigated to ${newRoute.name}`;

  // 3. Manage focus
  const mainContent = document.querySelector('#main-content');
  if (mainContent) {
    mainContent.setAttribute('tabindex', '-1');
    mainContent.focus();
  }
}

Testing Checklist

Manual testing is one of the best ways to confirm keyboard accessibility. To quickly simulate a keyboard-only experience, unplug your mouse.

  • Can every interactive element be reached with Tab?
  • Is the focus indicator always visible and high-contrast?
  • Is the tab order logical and intuitive?
  • Can all elements be activated with Enter or Space? (Check component-specific keys like arrows for sliders).
  • Are there any “keyboard traps” where focus cannot escape a component without closing it?
  • Does Esc consistently close modals, pop-ups, and menus?
  • Do skip links work as expected?
  • Are visually hidden elements removed from the tab order?

Troubleshooting

Quick solutions to common keyboard accessibility issues and pitfalls.

IssueAction
Focus disappearsCheck if display: none or visibility: hidden is being used. Use clip or visually-hidden class instead.
Tab order is wrongEnsure DOM order matches visual order. Avoid positive tabindex values.
Focus trap not workingVerify all focusable elements are included in the selector query.
Skip link not workingEnsure target element has tabindex="-1" and can receive programmatic focus.

To provide feedback or suggest improvements, please open a GitHub issue.

⋛⋋( ⊙◊⊙)⋌⋚