Skip to main content

Keyboard Navigation

Many users navigate the web entirely with a keyboard — people with motor impairments, power users, and anyone using assistive technology. If your site requires a mouse, you are excluding all of them.

The Basics

Keyboard navigation uses a small set of keys:

  • Tab — move focus to the next interactive element
  • Shift + Tab — move focus to the previous interactive element
  • Enter — activate links and buttons
  • Space — activate buttons, toggle checkboxes, open selects
  • Arrow keys — navigate within components (tabs, menus, radio groups)
  • Escape — close modals, menus, and popups

Only interactive elements receive focus by default: links (<a>), buttons (<button>), inputs (<input>), selects (<select>), and textareas (<textarea>).

Tab Order

The tab order follows the DOM order by default. This means your visual layout and source order should match. If they diverge, keyboard users will experience confusing focus jumps.

<!-- Good: visual order matches DOM order -->
<nav>
  <a href="/">Home</a>
  <a href="/about">About</a>
  <a href="/contact">Contact</a>
</nav>

<!-- Bad: CSS reordering breaks tab order -->
<nav style="display: flex; flex-direction: row-reverse;">
  <a href="/">Home</a>      <!-- Tabs first but appears last -->
  <a href="/about">About</a>
  <a href="/contact">Contact</a> <!-- Tabs last but appears first -->
</nav>

Avoid tabindex values greater than 0. They override the natural tab order and create maintenance nightmares. The only tabindex values you should regularly use are:

  • tabindex="0" — add an element to the natural tab order
  • tabindex="-1" — make an element focusable programmatically but not via Tab

Skip links let keyboard users jump past repetitive content like navigation menus:

<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header>
    <nav><!-- 20+ navigation links --></nav>
  </header>
  <main id="main-content">
    <h1>Page Title</h1>
    <!-- Main content -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  padding: 0.5rem 1rem;
  background: #000;
  color: #fff;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

The skip link is hidden off-screen until the user presses Tab, at which point it appears at the top of the viewport. This is the first thing a keyboard user encounters on every well-built website.

Focus Indicators

Never remove focus outlines without providing a replacement. The default browser outline tells keyboard users where they are:

/* Bad: removes all focus indicators */
*:focus {
  outline: none;
}

/* Good: custom focus indicator that is visible */
*:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

The :focus-visible pseudo-class applies focus styles only for keyboard navigation, not for mouse clicks. This gives you the best of both worlds — clean mouse interactions and visible keyboard focus.

Focus Traps

When a modal dialog opens, focus must be trapped inside it. Without a focus trap, pressing Tab will move focus behind the modal to invisible page content.

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

  dialog.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      // Shift+Tab from first element wraps to last
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      // Tab from last element wraps to first
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });

  // Move focus into the dialog
  first.focus();
}

When the modal closes, return focus to the element that triggered it. This preserves the user's place in the page.

Focus Management in SPAs

Single-page applications change content without a full page reload. When a route changes, you must manually manage focus:

  1. Move focus to the new page's <h1> or <main> element
  2. Announce the page change to screen readers via a live region or document title update
// After route change
const heading = document.querySelector('h1');
heading.setAttribute('tabindex', '-1');
heading.focus();

Practical Checklist

  • Tab through your entire page — can you reach every interactive element?
  • Is the focus order logical and matches the visual layout?
  • Can you see where focus is at all times?
  • Can you open and close modals with the keyboard?
  • Can you escape from every popup, dropdown, and overlay?
  • Does focus return to the trigger element when dialogs close?