Keyboard Navigation and Focus Management
1. Introduction
Keyboard accessibility is the foundation of all assistive technology support โ screen readers, switch devices, mouth sticks, and head trackers all rely on the browser’s keyboard navigation model. This lesson covers how the tab order is established, how to control focus programmatically with tabindex, and how to build keyboard-accessible custom components that follow established interaction patterns from the ARIA Authoring Practices Guide.
2. Concept
Focus and Tab Order Behaviour
| Element / Setting | Focusable by Default? | tabindex Needed? |
|---|---|---|
| Links, buttons, inputs, select, textarea | Yes | No |
| Custom widgets (div, span) | No | tabindex=”0″ to add to tab order |
| Programmatic focus target (modal, skip destination) | No | tabindex=”-1″ โ focusable via JS only |
| tabindex > 0 | Yes โ focused first | Avoid โ breaks natural DOM flow |
| hidden attribute, display:none | Removed from tab order | N/A |
tabindex values greater than 0. A tabindex="5" element is focused before any tabindex="0" or naturally focusable elements, creating a confusing and nearly impossible-to-maintain tab order.pointer-events: none hides elements from mouse interaction but does not remove them from the keyboard tab order. Use display: none, visibility: hidden, or the hidden attribute to remove elements from both mouse and keyboard interaction.3. Basic Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Focus Management Demo</title>
<style>
:focus-visible { outline: 3px solid #0070f3; outline-offset: 2px; }
[hidden] { display: none; }
</style>
</head>
<body>
<!-- tabindex="0": adds custom element to natural tab order -->
<div
role="button"
tabindex="0"
onclick="alert('Activated')"
onkeydown="if(event.key==='Enter'||event.key===' '){alert('Activated');}"
>
Custom Widget (tabindex="0")
</div>
<!-- tabindex="-1": focusable by JS, not in Tab sequence -->
<div
id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
hidden
>
<h2 id="modal-title">Confirm Deletion</h2>
<p>This action cannot be undone.</p>
<button type="button" id="confirm-btn">Delete</button>
<button type="button" id="cancel-btn">Cancel</button>
</div>
<button type="button" id="open-modal">Delete Item</button>
<script>
var openBtn = document.getElementById('open-modal');
var modal = document.getElementById('modal');
var confirmBtn = document.getElementById('confirm-btn');
var cancelBtn = document.getElementById('cancel-btn');
openBtn.addEventListener('click', function() {
modal.hidden = false;
confirmBtn.focus(); // move focus into dialog
});
function closeModal() {
modal.hidden = true;
openBtn.focus(); // return focus to trigger
}
cancelBtn.addEventListener('click', closeModal);
confirmBtn.addEventListener('click', function() {
closeModal();
});
modal.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>
4. How It Works
Step 1 โ Natural Tab Order
Tab moves focus through interactive elements in DOM order. Structure your HTML so DOM order matches the visual reading order โ never rely on CSS positioning alone to reorder content that is out-of-sequence in the HTML.
Step 2 โ tabindex=”0″
Adds a non-interactive element into the tab order at its natural DOM position. You must also add keyboard event handlers (Enter and Space keys) to make it operable. Because this is complex, always prefer a native <button> or <a> where possible.
Step 3 โ tabindex=”-1″
Makes an element focusable via element.focus() in JavaScript without adding it to the natural Tab sequence. Use for modal containers, skip link destinations, or the active item in a composite widget (like a tab panel or listbox).
Step 4 โ Focus Trap in Modals
When a modal is open, focus must cycle within it. Intercept Tab at the last focusable element (cycle back to first) and Shift+Tab at the first (cycle to last). This is WCAG 2.1.2 โ the trap must be intentional and escapable via the Escape key.
5. Real-World Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Tab Component</title>
<style>
[role="tabpanel"][hidden] { display: none; }
[role="tab"][aria-selected="true"] {
border-bottom: 3px solid #0070f3;
font-weight: bold;
}
:focus-visible { outline: 3px solid #0070f3; outline-offset: 2px; }
</style>
</head>
<body>
<div role="tablist" aria-label="Course chapters">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1" tabindex="0">HTML</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">CSS</button>
<button role="tab" id="tab-3" aria-selected="false" aria-controls="panel-3" tabindex="-1">JavaScript</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
<h3>HTML Fundamentals</h3>
<p>HTML provides the structure of web pages.</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabindex="0" hidden>
<h3>CSS Styling</h3>
<p>CSS controls visual presentation.</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" tabindex="0" hidden>
<h3>JavaScript Interactivity</h3>
<p>JavaScript adds dynamic behaviour.</p>
</div>
<script>
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
tabs.forEach(function(tab, i) {
tab.addEventListener('click', function() { activate(i); });
tab.addEventListener('keydown', function(e) {
if (e.key === 'ArrowRight') activate((i + 1) % tabs.length);
if (e.key === 'ArrowLeft') activate((i - 1 + tabs.length) % tabs.length);
if (e.key === 'Home') activate(0);
if (e.key === 'End') activate(tabs.length - 1);
});
});
function activate(index) {
tabs.forEach(function(t, j) {
var on = j === index;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
});
tabs[index].focus();
}
</script>
</body>
</html>
6. Common Mistakes
❌ Positive tabindex values โ breaks tab order unpredictably
<input tabindex="3">
<input tabindex="1">
<input tabindex="2">
✓ Let DOM order determine tab order; never use tabindex > 0
<input> <!-- first in tab order -->
<input> <!-- second -->
<input> <!-- third -->
❌ Opening modal without moving focus into it
<button onclick="modal.hidden=false">Open</button>
✓ Move focus to first interactive element after opening
<button onclick="modal.hidden=false; modal.querySelector('button').focus()">Open</button>
7. Try It Yourself
8. Quick Reference
| tabindex Value | Effect | Use For |
|---|---|---|
| tabindex=”0″ | Adds to tab order at natural DOM position | Custom interactive widgets |
| tabindex=”-1″ | Programmatically focusable; not in Tab order | Modal containers, skip targets, composite items |
| tabindex > 0 | Focused before all natural elements | Avoid always โ breaks expected order |
| (omitted) | Native elements focusable in DOM order | Links, buttons, inputs, select, textarea |
| hidden attribute | Removed from tab order and visually hidden | Collapsed panels, inactive tabs |