Skip to main content
Back to Blog
Developer Guide June 19, 2026

Keyboard Navigation Best Practices for Web Apps

Keyboard Navigation Best Practices for Web Apps

Keyboard navigation is the accessibility feature that most directly affects the largest number of users. People with motor impairments, power users, screen reader users, and anyone who simply prefers the keyboard over the mouse all depend on it. And yet it's one of the most commonly broken aspects of modern web apps.

Here's what robust keyboard navigation looks like, and the specific patterns that prevent the most common failures.


Why Keyboard Navigation Fails in Modern Apps

The web platform provides keyboard navigation for free on native elements: links, buttons, inputs, selects, and form elements are all focusable and keyboard-operable by default. The problem is that modern UI frameworks make it easy to build interactive behavior on non-semantic elements — <div> click handlers, custom dropdowns with <ul>, modal implementations without focus management — that silently break keyboard access.

The three root causes:

  1. Non-semantic interactive elements<div> and <span> elements with click handlers that aren't keyboard-operable
  2. Missing or broken focus management — focus disappears or resets unexpectedly when UI state changes
  3. Invisible focus indicators — removed with outline: none; outline: 0 without a replacement

Tab Order

Tab order — the sequence in which focus moves when a user presses Tab — should follow reading order (top-to-bottom, left-to-right for LTR layouts). Three rules:

Use the DOM order as the source of truth

Tab order follows DOM order by default. When visual layout and DOM order diverge (e.g., using CSS Grid to reorder elements visually), keyboard focus still follows the DOM, creating a disconnect between what sighted keyboard users see and where focus actually goes.

/* Bad: visual order differs from DOM order */
.nav { order: -1; } /* visually first, but DOM-last */

Keep your DOM order consistent with your visual reading order. CSS order, float, and absolute positioning shouldn't reorder semantically important elements.

Don't use positive tabindex values

<!-- Bad: arbitrary positive tabindex values break natural order -->
<button tabindex="3">Submit</button>
<input tabindex="1" />
<a tabindex="2" href="/">Home</a>

<!-- Good: rely on DOM order -->
<a href="/">Home</a>
<input />
<button>Submit</button>

Positive tabindex values create a separate, prioritized sequence that's almost impossible to maintain across a codebase. Use 0 (participates in natural order) or -1 (focusable by script, removed from tab order) only.

Skip repetitive navigation

Users navigating by keyboard shouldn't have to Tab through your entire header navigation to reach page content on every page load. Provide a skip link:

<!-- First element in <body>, visually hidden but visible on focus -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<header>...</header>
<main id="main-content">...</main>
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  padding: 0.5rem 1rem;
  background: #7c3aed;
  color: white;
  font-weight: bold;
  text-decoration: none;
  z-index: 9999;
}
.skip-link:focus {
  top: 0;
}

Focus Indicators

Removing the default focus ring is one of the most common accessibility failures:

/* Bad: hides focus state for all users */
* { outline: none; }
button:focus { outline: 0; }

The default browser focus ring is visually inconsistent but functionally correct. If you don't like the default, replace it — don't remove it:

/* Good: custom focus ring that's visible and distinct */
:focus-visible {
  outline: 2px solid #7c3aed;
  outline-offset: 2px;
}

/* WCAG 2.2 SC 2.4.11: minimum 3:1 contrast against adjacent colors */

Use :focus-visible rather than :focus for the custom style. :focus-visible only shows the ring when a keyboard or keyboard-equivalent device triggered focus — not on mouse click — which is what most designers object to when they remove outline.

WCAG 2.2 SC 2.4.11 (Focus Appearance) requires focus indicators to:

  • Have a perimeter of at least the element's perimeter × 1 (full border equivalent)
  • Have at least 3:1 contrast against adjacent unfocused colors

Keyboard-Operable Custom Components

When you can't use native HTML elements, you must implement keyboard interaction manually.

Custom buttons (avoid if possible)

<!-- If you must use a div as a button -->
<div
  role="button"
  tabindex="0"
  onkeydown="handleKey(event)"
  onclick="handleClick()"
>
  Custom action
</div>
function handleKey(event) {
  if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault(); // Prevent Space from scrolling
    handleClick();
  }
}

But seriously — just use <button>. It handles all of this natively.

Dropdown menus

Dropdowns require specific keyboard behavior per the ARIA Authoring Practices Guide (APG):

  • Enter/Space on trigger: opens menu, moves focus to first item
  • Escape: closes menu, returns focus to trigger
  • Arrow Down/Up: moves focus between menu items
  • Tab while open: closes menu (don't trap Tab in menus)
  • Home/End: moves focus to first/last item
<button
  id="menu-btn"
  aria-haspopup="true"
  aria-expanded="false"
  aria-controls="menu-list"
>
  Options
</button>
<ul
  id="menu-list"
  role="menu"
  aria-labelledby="menu-btn"
  hidden
>
  <li role="menuitem" tabindex="-1">Edit</li>
  <li role="menuitem" tabindex="-1">Delete</li>
</ul>

The menu items use tabindex="-1" so they're focusable by script but not in the natural tab order. When the menu opens, move focus to the first item with .focus().

Tabs (tabpanel pattern)

<div role="tablist" aria-label="Settings">
  <button role="tab" aria-selected="true" aria-controls="panel-general" id="tab-general">General</button>
  <button role="tab" aria-selected="false" aria-controls="panel-privacy" id="tab-privacy" tabindex="-1">Privacy</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">...</div>
<div role="tabpanel" id="panel-privacy" aria-labelledby="tab-privacy" hidden>...</div>

Tab panels use the roving tabindex pattern: only the selected tab has tabindex="0". Others get tabindex="-1". Arrow keys move between tabs (not Tab key — Tab should move into the panel content).


Focus Management in Dynamic UIs

This is where most modern apps fail. Any time you change UI state that affects what's on screen, you need to actively manage where focus is.

Opening a modal

When a modal opens:

  1. Move focus to the first focusable element inside the modal (or to the modal itself if it has a heading)
  2. Trap Tab/Shift+Tab inside the modal — focus should not reach elements behind it
  3. When the modal closes, return focus to the element that triggered it
function openModal(triggerEl, modalEl) {
  modalEl.removeAttribute('hidden');
  const focusable = modalEl.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  focusable[0]?.focus();

  // Store trigger for return focus
  modalEl._trigger = triggerEl;
}

function closeModal(modalEl) {
  modalEl.setAttribute('hidden', '');
  modalEl._trigger?.focus(); // Return focus to trigger
}

For focus trapping, listen for keydown on the modal and wrap Tab from the last focusable element back to the first (and Shift+Tab from the first back to the last).

After dynamic content loads

When async content replaces a section of the page (search results, pagination, lazy-loaded content):

// After results render, move focus to the results heading
// so keyboard users know new content has arrived
document.getElementById('results-heading').focus();

The heading needs tabindex="-1" to be programmatically focusable without entering the natural tab order:

<h2 id="results-heading" tabindex="-1">24 results</h2>

After deleting an item from a list

When a user deletes the last focused item from a list:

function deleteItem(item) {
  const next = item.nextElementSibling || item.previousElementSibling;
  item.remove();
  if (next) {
    next.focus(); // Move to adjacent item
  } else {
    document.getElementById('items-list').focus(); // Or the list container
  }
}

Never let focus disappear into <body>. A keyboard user who loses focus has to Tab from the beginning of the page.


Testing Keyboard Navigation

The manual test: Close your mouse. Navigate your entire application using only the keyboard. Every interactive element must be reachable, operable, and have a visible focus indicator.

Key checks:

  • Tab through the page — focus should visit every interactive element in a logical order
  • Shift+Tab reverses the sequence correctly
  • Enter activates links and buttons; Space also activates buttons
  • Escape closes modals, dropdowns, and other overlays and returns focus
  • Arrow keys work within components that use the roving tabindex pattern (tabs, menus, radio groups)
  • No focus traps outside of modal dialogs

Automated support:

  • axe-core catches missing tabindex, missing labels, and some focus management issues
  • Cypress + cypress-axe can run checks on each page in your test suite
  • The browser accessibility tree (DevTools → Accessibility panel) shows the tab order visually in Chrome

Quick Checklist

  • No outline: none without a replacement :focus-visible style
  • Focus indicator has 3:1 contrast against adjacent colors
  • Skip link present and works
  • All interactive elements are native HTML or have role + keyboard handling
  • Tab order matches reading order (no mismatched CSS order)
  • Modals trap focus and return it on close
  • Focus moves to new content after async updates
  • Deleted/removed items pass focus to an adjacent element
  • No positive tabindex values in the codebase