Skip to content

Keyboard Navigation and Tab Order

intermediate18 min read

Why Keyboard Navigation Matters More Than You Think

Here's a stat that might surprise you: keyboard navigation isn't just for screen reader users. Power users, developers, people with motor impairments, people with a broken trackpad, people using TV remotes — all of them rely on the keyboard. Some studies estimate that up to 25% of users use keyboard navigation at least some of the time.

And here's the uncomfortable truth: if your site only works with a mouse, you've broken it for a significant chunk of your users. Not "degraded the experience" — broken it. A button that can't be reached with Tab is as useless as a button hidden behind a white div.

The good news? The browser gives you keyboard navigation for free — if you use the right HTML elements. The bad news? It's shockingly easy to break it, and most developers do without realizing it.

Mental Model

Think of tab order like a single-lane road through your page. The browser lays out this road based on the DOM order of focusable elements. Each press of Tab moves one stop forward along the road. Shift+Tab goes backward. Every interactive element — links, buttons, inputs — is a stop on this road. Non-interactive elements like div and span are not stops at all, unless you explicitly add them. The moment you start manually rearranging stops (positive tabindex), you create a confusing detour that breaks the natural flow.

Tab Order Follows DOM Order

This is the foundational rule that everything else builds on: the order elements receive focus when you press Tab matches the order those elements appear in the DOM, not their visual position on screen.

Naturally focusable elements (the ones that get a stop on the tab road without any extra work):

  • a with an href attribute
  • button (not disabled)
  • input, select, textarea (not disabled)
  • details / summary
  • Elements with contenteditable
<!-- Tab order: Name → Email → Subscribe → Learn More -->
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button>Subscribe</button>
<a href="/about">Learn More</a>

This order is intuitive because the DOM order matches the visual order. But watch what happens when CSS reorders the visual layout:

<style>
  .container { display: flex; flex-direction: row-reverse; }
</style>

<div class="container">
  <button>First in DOM, last visually</button>
  <button>Second in DOM, first visually</button>
</div>

Visually, users see "Second" on the left and "First" on the right. But Tab goes to "First" first — because it's first in the DOM. The user sees focus jump to the right side, then back to the left. Confusing.

Common Trap

CSS order, flex-direction: row-reverse, grid placement, and position: absolute all create visual reordering without changing DOM order. Tab order follows the DOM, not the visual layout. If your visual and DOM orders don't match, keyboard users will experience focus jumping around the page randomly. Fix the DOM order to match visual order, or use CSS that does not reorder (preferred).

Quiz
You have three buttons styled with CSS Grid. Button A is in grid column 3, Button B in column 1, and Button C in column 2. The DOM order is A, B, C. What is the Tab order?

The tabindex Attribute

tabindex gives you control over which elements are focusable and how they participate in the tab order. But it's a scalpel, not a hammer — most of the time, you don't need it at all.

There are exactly three values you should know:

tabindex 0 — Add to the Tab Order

Makes a non-interactive element focusable and places it in the natural tab order (based on DOM position). Use this when you're building a custom interactive widget from a div or span.

<!-- This div is now focusable via Tab, in DOM order -->
<div tabindex="0" role="button" aria-label="Close dialog">
  X
</div>

But here's the thing — if you're adding tabindex="0", role="button", and an aria-label to a div, you should probably just use a button element. The button gives you all of that for free, plus keyboard activation (Enter and Space), plus form submission, plus correct accessibility semantics.

tabindex -1 — Focusable, But Not in Tab Order

Makes an element focusable via JavaScript (element.focus()), but removes it from the Tab key sequence. Users can't Tab to it, but your code can send focus there programmatically.

This is essential for:

  • Focus management — moving focus to a modal when it opens, to an error message after form validation, or to a section after a skip link
  • Roving tabindex — only one item in a widget group is tabbable (covered later)
  • Non-interactive but scrollable containers — a code block that needs to be scrollable via keyboard
// After opening a modal, move focus to its heading
const modal = document.getElementById('modal-title');
modal.focus(); // Works because it has tabindex="-1"
<h2 id="modal-title" tabindex="-1">Confirm deletion</h2>

Positive tabindex Values — Never Do This

Values like tabindex="1", tabindex="5", or tabindex="100" place the element before everything with tabindex="0" or no tabindex. Elements with positive tabindex are visited first (in ascending order), then all the natural tab-order elements follow.

<!-- DON'T: Tab order becomes Help → Settings → Name → Email → Submit -->
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button>Submit</button>
<button tabindex="1">Help</button>
<button tabindex="2">Settings</button>

This is almost always wrong. It creates a tab order that diverges from the visual and DOM order, confusing every keyboard user. It's also unmaintainable — adding a new element means recalculating all the positive values.

The WCAG rule

WCAG 2.1 Success Criterion 2.4.3 (Focus Order) requires that focus order preserves meaning and operability. Positive tabindex values almost always violate this by creating a tab order that does not match the visual or logical order of the page.

Quiz
You need to programmatically move focus to an error summary after form validation fails. The error summary is a non-interactive heading. What tabindex value should it have?

focus-visible vs focus

When you style :focus, you're styling every focus event — keyboard, mouse click, programmatic. This leads to a common complaint: "Why does my button have an ugly focus ring when I click it with my mouse?"

:focus-visible solves this. It only applies focus styles when the browser determines the user is navigating via keyboard (or needs a visible focus indicator). Mouse clicks typically don't trigger :focus-visible.

/* BAD: Shows focus ring on mouse click too */
button:focus {
  outline: 2px solid var(--color-accent);
}

/* GOOD: Only shows focus ring for keyboard navigation */
button:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* Remove default outline, rely on focus-visible */
button:focus:not(:focus-visible) {
  outline: none;
}
Browser support

:focus-visible is supported in all modern browsers. The browser uses heuristics to decide when to apply it — generally, Tab and Shift+Tab triggers it, mouse clicks do not. For text inputs, :focus-visible is always applied because typing requires a visible cursor position.

The key insight: never remove focus outlines globally. This is one of the most damaging accessibility mistakes on the web. If you want to customize the appearance, use :focus-visible to show a styled indicator for keyboard users while hiding it for mouse users.

Quiz
You globally remove all focus outlines from your site. What happens for keyboard users?

Imagine you're a keyboard user on a complex site. You press Tab to navigate. First you go through the logo, then the main nav (Home, About, Courses, Blog, Contact), then the search bar, then the user menu. That's 8-10 Tab presses before you reach the actual page content. Now imagine doing that on every single page.

Skip links solve this. They're links that are visually hidden but appear when focused, allowing keyboard users to jump directly to the main content.

<body>
  <!-- First focusable element on the page -->
  <a href="#main-content" class="skip-link">
    Skip to main content
  </a>

  <nav><!-- lots of nav items --></nav>

  <main id="main-content" tabindex="-1">
    <!-- Page content -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -100%;
  left: 16px;
  padding: 8px 16px;
  background: var(--color-bg);
  color: var(--color-text);
  border: 2px solid var(--color-accent);
  border-radius: 4px;
  z-index: 1000;
  font-size: 0.875rem;
}

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

Notice two things:

  1. The skip link is the first focusable element in the DOM, so it's the first thing a Tab press reaches
  2. The main element has tabindex="-1" so the skip link can send focus to it (non-interactive elements are not focusable by default)
Multiple skip links

Large applications sometimes benefit from multiple skip links — "Skip to main content", "Skip to search", "Skip to footer". GitHub uses this pattern. Each skip link targets a landmark with tabindex="-1". Group them in a list at the top of the page so they appear sequentially on first Tab press.

Keyboard Patterns Beyond Tab

Tab is only for moving between independent interactive elements. Inside composite widgets — toolbars, tab lists, menus, tree views, listboxes — the keyboard pattern changes completely. This is where most developers get keyboard navigation wrong.

The Principle

The WAI-ARIA Authoring Practices define specific keyboard patterns for each widget type. The core idea: Tab moves between widgets, arrow keys move within a widget.

Think of it like a spreadsheet. Tab moves you to the next cell. Arrow keys move within the current cell's dropdown.

Common Keyboard Patterns

Enter and Space — Activation

  • Enter activates links and buttons
  • Space activates buttons and toggles checkboxes
  • Links should not activate on Space (this is a real difference between links and buttons)

Escape — Dismissal

  • Close modals, dropdowns, popovers, tooltips
  • Return focus to the element that triggered the dismissed widget

Arrow Keys — Navigation Within Widgets

WidgetKeysBehavior
Tab listLeft/RightSwitch between tabs
MenuUp/DownMove between menu items
Tree viewUp/Down, Left/RightNavigate and expand/collapse
ListboxUp/DownSelect option
ToolbarLeft/RightMove between tools
Date pickerArrow keysNavigate days/weeks

Home / End

  • Move to first/last item in a list, menu, or tab panel

Example: Tab List Keyboard Pattern

<div role="tablist" aria-label="Settings">
  <button role="tab" aria-selected="true" id="tab-1"
          aria-controls="panel-1" tabindex="0">
    General
  </button>
  <button role="tab" aria-selected="false" id="tab-2"
          aria-controls="panel-2" tabindex="-1">
    Security
  </button>
  <button role="tab" aria-selected="false" id="tab-3"
          aria-controls="panel-3" tabindex="-1">
    Notifications
  </button>
</div>

Notice: only the active tab has tabindex="0". The others have tabindex="-1". This means:

  • Tab lands on the active tab, then skips the entire tab list to the next widget
  • Arrow keys move between tabs within the list
  • The user does not have to Tab through every single tab — they Tab once into the widget, use arrows to navigate, then Tab out

This pattern is called roving tabindex, and it's the most important keyboard pattern for composite widgets.

The Roving tabindex Pattern

Roving tabindex means exactly one element in a group has tabindex="0" (the "rover"), and all others have tabindex="-1". When the user presses an arrow key, you move tabindex="0" to the next element and focus it.

function handleKeyDown(event, items, currentIndex) {
  let nextIndex = currentIndex;

  switch (event.key) {
    case 'ArrowRight':
    case 'ArrowDown':
      nextIndex = (currentIndex + 1) % items.length;
      break;
    case 'ArrowLeft':
    case 'ArrowUp':
      nextIndex = (currentIndex - 1 + items.length) % items.length;
      break;
    case 'Home':
      nextIndex = 0;
      break;
    case 'End':
      nextIndex = items.length - 1;
      break;
    default:
      return;
  }

  event.preventDefault();

  items[currentIndex].setAttribute('tabindex', '-1');
  items[nextIndex].setAttribute('tabindex', '0');
  items[nextIndex].focus();
}

Key details of roving tabindex:

  1. Arrow keys wrap around — pressing Right on the last item moves to the first (using modulo)
  2. Home/End jump to first/last item
  3. Only the focused item has tabindex 0 — so Tab moves out of the widget, not through every item
  4. Focus follows the roving tabindex — calling .focus() after updating the attribute

Why Not Just Use Tab for Everything?

Imagine a toolbar with 20 buttons. If each one is a tab stop, a keyboard user has to press Tab 20 times to get past the toolbar. With roving tabindex, they Tab once to enter the toolbar, arrow through the buttons, then Tab once to leave. That's 2 Tab presses instead of 20.

This principle — Tab between widgets, arrows within widgets — is what makes keyboard navigation scalable.

Quiz
A toolbar has 5 buttons. Using roving tabindex, how many times does a keyboard user need to press Tab to move past the entire toolbar?

Key Event Handling: keydown, Not keypress

When handling keyboard events, always use keydown. Here's why:

  • keypress is deprecated. It does not fire for non-character keys (Escape, arrows, Tab, Enter in some cases). Browsers are inconsistent about which keys trigger it. Don't use it.
  • keydown fires for every key, including modifier keys. It fires before the browser's default action, so you can call preventDefault() to stop it.
  • keyup fires after the key is released. Useful for some cases (preventing repeated actions on key hold), but keydown is the primary handler for keyboard navigation.
element.addEventListener('keydown', (event) => {
  switch (event.key) {
    case 'Enter':
    case ' ':
      event.preventDefault();
      activateItem();
      break;
    case 'Escape':
      closeWidget();
      restoreFocus();
      break;
    case 'ArrowDown':
      event.preventDefault();
      focusNextItem();
      break;
  }
});

Important: always use event.key (returns 'Enter', 'Escape', 'ArrowDown'), not event.keyCode (returns numbers like 13, 27, 40). keyCode is deprecated and inconsistent across keyboard layouts.

Common Trap

Forgetting event.preventDefault() on arrow keys inside a scrollable container causes the page to scroll while you're trying to navigate within the widget. Always prevent default on keys you're handling — otherwise the browser's default behavior (scroll, form submit, link follow) will fire alongside your custom behavior.

Focus Trapping in Modals

When a modal dialog is open, Tab should cycle only through focusable elements inside the modal — it should never escape to elements behind the modal. This is called a focus trap.

function trapFocus(modalElement) {
  const focusableSelectors = [
    'a[href]',
    'button:not([disabled])',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ].join(', ');

  const focusables = modalElement.querySelectorAll(focusableSelectors);
  const firstFocusable = focusables[0];
  const lastFocusable = focusables[focusables.length - 1];

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

    if (event.shiftKey) {
      if (document.activeElement === firstFocusable) {
        event.preventDefault();
        lastFocusable.focus();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        event.preventDefault();
        firstFocusable.focus();
      }
    }
  });
}

And when the modal closes, focus must return to the element that opened it:

function openModal(triggerElement) {
  const modal = document.getElementById('modal');
  modal.style.display = 'block';
  modal.querySelector('[tabindex="-1"]').focus();

  modal.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
      modal.style.display = 'none';
      triggerElement.focus();
    }
  });
}
The dialog element

The HTML dialog element with the showModal() method handles focus trapping natively — it traps Tab, handles Escape to close, and prevents interaction with content behind the dialog. If you can use it, do. It is supported in all modern browsers and eliminates the need for custom focus trap code.

Putting It All Together: An Accessible Dropdown Menu

Let's combine everything — roving tabindex, arrow key navigation, Escape to close, focus management:

<div class="menu-container">
  <button
    aria-haspopup="true"
    aria-expanded="false"
    id="menu-trigger">
    Options
  </button>

  <ul role="menu" aria-labelledby="menu-trigger" hidden>
    <li role="menuitem" tabindex="-1">Edit</li>
    <li role="menuitem" tabindex="-1">Duplicate</li>
    <li role="menuitem" tabindex="-1">Delete</li>
  </ul>
</div>
const trigger = document.getElementById('menu-trigger');
const menu = document.querySelector('[role="menu"]');
const items = menu.querySelectorAll('[role="menuitem"]');

trigger.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' || event.key === ' '
      || event.key === 'ArrowDown') {
    event.preventDefault();
    openMenu();
  }
});

function openMenu() {
  menu.hidden = false;
  trigger.setAttribute('aria-expanded', 'true');
  items[0].focus();
}

function closeMenu() {
  menu.hidden = true;
  trigger.setAttribute('aria-expanded', 'false');
  trigger.focus();
}

menu.addEventListener('keydown', (event) => {
  const currentIndex = Array.from(items).indexOf(
    document.activeElement
  );

  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault();
      items[(currentIndex + 1) % items.length].focus();
      break;
    case 'ArrowUp':
      event.preventDefault();
      items[
        (currentIndex - 1 + items.length) % items.length
      ].focus();
      break;
    case 'Home':
      event.preventDefault();
      items[0].focus();
      break;
    case 'End':
      event.preventDefault();
      items[items.length - 1].focus();
      break;
    case 'Escape':
      closeMenu();
      break;
    case 'Enter':
    case ' ':
      event.preventDefault();
      items[currentIndex].click();
      closeMenu();
      break;
  }
});

This dropdown follows every keyboard pattern from the WAI-ARIA Authoring Practices:

  1. Enter/Space/ArrowDown on the trigger opens the menu and focuses the first item
  2. Arrow keys navigate between items (with wrapping)
  3. Home/End jump to first/last item
  4. Enter/Space on an item activates it and closes the menu
  5. Escape closes the menu and returns focus to the trigger
  6. Tab is not used within the menu — it should close the menu and move to the next widget
Quiz
A dropdown menu is open. The user presses Escape. What should happen?
Quiz
You are building a custom listbox with 8 options. A keyboard user presses Tab while focus is on the 3rd option. What should happen?
What developers doWhat they should do
Using positive tabindex values to control tab order
Positive tabindex creates a separate focus sequence that runs before all natural tab stops, making navigation unpredictable and unmaintainable
Fix the DOM order to match the desired tab order. Only use tabindex 0 and tabindex -1
Removing focus outlines globally
Removing focus outlines makes it impossible for keyboard users to see which element has focus — they navigate completely blind (WCAG 2.4.7 violation)
Use :focus-visible to show styled focus indicators for keyboard users only
Making every element in a widget a Tab stop
A toolbar with 20 buttons should be 1 Tab stop, not 20. Tab moves between widgets, arrow keys move within widgets
Use roving tabindex: one Tab stop per widget, arrow keys to navigate within
Using keypress events for keyboard navigation
keypress is deprecated, does not fire for non-character keys (Escape, arrows), and has inconsistent browser behavior. keydown fires for all keys
Use keydown with event.key instead of event.keyCode
Closing a modal or dropdown without returning focus to the trigger
Without focus restoration, keyboard users lose their place on the page and have to re-Tab through everything to find where they were
Always call triggerElement.focus() when dismissing an overlay
Using a clickable div instead of a button element
A clickable div is invisible to keyboard users (not focusable) and screen readers (no semantic role). Native button gives you focusability, Enter/Space activation, and correct semantics for free
Use a native button element for interactive controls
Key Rules
  1. 1Tab order follows DOM order, not visual order. CSS reordering (Flexbox, Grid) does not change tab order.
  2. 2Only use tabindex 0 (add to tab order) and tabindex -1 (programmatic focus only). Never use positive values.
  3. 3Use :focus-visible for keyboard-only focus styles. Never globally remove focus outlines.
  4. 4Tab moves between widgets, arrow keys move within widgets. This is the roving tabindex pattern.
  5. 5Always handle keydown, not keypress. Use event.key, not event.keyCode. Both keypress and keyCode are deprecated.
  6. 6When dismissing modals or dropdowns via Escape, always return focus to the trigger element.
  7. 7Skip links let keyboard users bypass repetitive navigation. They should be the first focusable element in the DOM.