Keyboard Navigation Done Right

WebAccessibilityFrontendUX
Share on LinkedIn

I tabbed through a checkout flow to test keyboard accessibility. At the payment modal, focus escaped behind the overlay — I was tabbing through the page header while the modal was visually open. At the address autocomplete, arrow keys scrolled the page instead of selecting suggestions. At the success screen, focus reset to the top of the page and I had no idea the form had submitted. Three separate focus management failures in one flow. Keyboard navigation isn't about adding tabindex attributes — it's about controlling where focus goes at every state transition.

The keyboard navigation baseline

Every interactive element must be:

  1. Reachable — Tab moves focus to it
  2. Operable — Enter/Space activates it
  3. Visible — Focus indicator is clearly visible
  4. Logical — Tab order follows visual reading order

Native HTML elements (<button>, <a>, <input>, <select>) handle 1-3 automatically. Custom widgets need explicit implementation.

Focus indicators

Never remove focus outlines without replacing them:

/* BAD: removes focus for everyone */
*:focus { outline: none; }

/* GOOD: custom but visible focus ring */
:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

/* Even better: ring that respects border-radius */
:focus-visible {
  box-shadow: 0 0 0 2px white, 0 0 0 4px #2563eb;
}

:focus-visible shows the ring only for keyboard focus, not mouse clicks. This is the modern standard.

Skip links

Let keyboard users bypass repetitive navigation:

<a href="#main-content" class="skip-link">Skip to main content</a>

<header><!-- long navigation --></header>
<main id="main-content" tabindex="-1">
  <!-- page content -->
</main>
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  padding: 0.5rem 1rem;
  background: #2563eb;
  color: white;
  z-index: 100;
}
.skip-link:focus {
  top: 0;
}

The tabindex="-1" on main allows programmatic focus when the skip link is activated.

Tab order control

Tab order follows DOM order by default. Control it with:

Never use positive tabindex values — they create confusing, brittle order.

<!-- Custom button that needs to be in tab order -->
<div role="button" tabindex="0" @keydown.enter="activate" @keydown.space.prevent="activate">
  Custom action
</div>

Focus management patterns

Modal open/close

function openModal(trigger: HTMLElement) {
  const modal = document.getElementById('modal');
  lastFocusedElement = trigger;
  modal.showModal(); // native <dialog>
  // focus first focusable element inside modal
  const firstFocusable = modal.querySelector<HTMLElement>(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  firstFocusable?.focus();
}

function closeModal() {
  modal.close();
  lastFocusedElement?.focus(); // return focus to trigger
}

List item deletion

function deleteItem(index: number) {
  items.splice(index, 1);
  // Move focus to next item, or previous if last was deleted
  const nextIndex = Math.min(index, items.length - 1);
  if (nextIndex >= 0) {
    focusItem(nextIndex);
  } else {
    addButton.focus(); // list is empty, focus the add button
  }
}

Route change

router.afterEach((to) => {
  // Move focus to main content on navigation
  const main = document.getElementById('main-content');
  main?.focus();
  document.title = to.meta.title;
});

Focus trapping

For custom modals without <dialog>:

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

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

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

Or use the focus-trap library, which handles edge cases (empty containers, dynamically added elements, Shadow DOM).

Keyboard patterns for custom widgets

Widget Keys Behavior
Tabs Arrow L/R, Home, End Move between tabs, activate
Menu Arrow U/D, Enter, Escape Navigate items, select, close
Combobox Arrow U/D, Enter, Escape Navigate options, select, close
Tree Arrow U/D/L/R Expand, collapse, navigate
Slider Arrow L/R Decrease/increase value
Dialog Escape, Tab Close, trap focus

Follow the WAI-ARIA APG keyboard patterns for each widget type.

Roving tabindex

For composite widgets (toolbars, tab lists, radio groups), only one child is in the tab order at a time:

<div role="toolbar">
  <button tabindex="0">Bold</button>     <!-- in tab order -->
  <button tabindex="-1">Italic</button>   <!-- arrow key navigation -->
  <button tabindex="-1">Underline</button>
</div>

Arrow keys move between toolbar buttons and update tabindex — the focused button gets 0, others get -1. This prevents tabbing through every button in a toolbar.

Testing keyboard navigation

  1. Unplug your mouse. Use the site for 10 minutes.
  2. Tab through every page. Is the order logical? Is focus visible?
  3. Open every modal and dropdown. Does focus stay contained?
  4. Delete items, submit forms, navigate routes. Does focus land somewhere sensible?
  5. Run axe-core for automated tabindex and focusable element checks.

Common production mistakes

Teams get accessibility keyboard navigation wrong in predictable ways:

Production implementations of accessibility keyboard navigation fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

Why is keyboard navigation important for web accessibility?

Keyboard navigation is essential for users who cannot use a mouse — people with motor disabilities, power users who prefer keyboard efficiency, and users of screen readers who navigate by tabbing between interactive elements. WCAG 2.1 requires that all functionality be operable via keyboard without requiring specific timings for individual keystrokes. If a feature works with a mouse but not a keyboard, it is an accessibility failure.

What is focus management and when do I need it?

Focus management is the practice of controlling which element receives keyboard focus at key moments — opening a modal moves focus inside it, closing returns focus to the trigger, deleting an item moves focus to the next item. Without focus management, keyboard users lose their place after dynamic changes. Every interactive state change (open, close, add, remove, navigate) should have an explicit focus target.

How do I implement a focus trap for modals?

A focus trap keeps Tab and Shift+Tab cycling within the modal while it is open, preventing focus from escaping to the background page. Implement it by listening for Tab on the modal container, finding all focusable children, and redirecting focus from the last element to the first (or vice versa on Shift+Tab). The dialog element provides this behavior natively in supporting browsers. For custom modals, use a library like focus-trap or implement the keydown handler manually.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →