Keyboard Navigation Done Right
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:
- Reachable — Tab moves focus to it
- Operable — Enter/Space activates it
- Visible — Focus indicator is clearly visible
- 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:
- DOM order — the right way. Rearrange elements in HTML.
tabindex="0"— add an element to the natural tab ordertabindex="-1"— focusable programmatically but not via Tab
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
- Unplug your mouse. Use the site for 10 minutes.
- Tab through every page. Is the order logical? Is focus visible?
- Open every modal and dropdown. Does focus stay contained?
- Delete items, submit forms, navigate routes. Does focus land somewhere sensible?
- Run axe-core for automated tabindex and focusable element checks.
Common production mistakes
Teams get accessibility keyboard navigation wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
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
- WCAG 2.1 Keyboard Accessible
- WAI-ARIA APG keyboard patterns
- focus-trap library
- MDN :focus-visible
- WebAIM keyboard accessibility
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 →