Overview
Focus management is the invisible backbone of keyboard accessibility. Every time a user opens a panel, closes a dialog, or navigates a toolbar, the answer to “where does focus go next?” determines whether the experience is usable or broken. Poor focus management leaves keyboard and screen reader users stranded — they press Tab and land somewhere unpredictable, or worse, somewhere invisible.
WCAG Criteria:
- 2.1.1 Keyboard — all functionality must be operable via keyboard
- 2.4.3 Focus Order — focus order must be meaningful and predictable
- 2.4.7 Focus Visible — keyboard focus indicator must always be visible
Key requirements:
tabindex="0": adds an element to the natural Tab ordertabindex="-1": makes an element focusable via JavaScript only (removes it from Tab order)tabindex="1+": never use — it overrides natural DOM order and creates unpredictable navigation- Focus trapping is required for modals — Tab/Shift+Tab must cycle within the dialog
- Focus restoration is required when any overlay or panel closes — return focus to the trigger
- Roving tabindex makes composite widgets (toolbars, tablists, menus) a single Tab stop
- Always ask: “After this interaction, where does focus go?”
Focus Restoration
Focus Restoration on Panel Close vs. Focus Lost to Body
Inaccessible
<!-- No focus management — closing the panel leaves focus stranded -->
<button id="settings-trigger" onclick="
var panel = document.getElementById('settings-panel');
if (panel.hidden) { panel.removeAttribute('hidden'); }
else { panel.setAttribute('hidden', ''); }
">
Settings
</button>
<div id="settings-panel" hidden>
<h3>Settings</h3>
<label><input type="checkbox"> Enable notifications</label>
<label><input type="checkbox"> Dark mode</label>
<button onclick="
document.getElementById('settings-panel').setAttribute('hidden', '');
/* Focus goes nowhere — user is lost */
">Close</button>
</div>Live Preview
Settings
Accessible
<button id="settings-trigger" onclick="
var panel = document.getElementById('settings-panel');
if (panel.hidden) {
panel.removeAttribute('hidden');
document.getElementById('close-btn').focus();
} else {
panel.setAttribute('hidden', '');
this.focus();
}
">
Settings
</button>
<div id="settings-panel" hidden>
<h3>Settings</h3>
<label><input type="checkbox"> Enable notifications</label>
<label><input type="checkbox"> Dark mode</label>
<button id="close-btn" onclick="
document.getElementById('settings-panel').setAttribute('hidden', '');
document.getElementById('settings-trigger').focus();
">Close</button>
</div>Live Preview
Settings
What’s wrong with the bad example?
- When the user clicks “Close”, the settings panel disappears but focus is not moved anywhere
- The browser resets focus to
<body>, which means a keyboard user must Tab from the very top of the page to get back to where they were - A screen reader user hears nothing after the panel closes — they have no idea where they are on the page
- Opening the panel also does not move focus into it, so keyboard users must Tab forward to find the new content
What the screen reader announces:
| Version | After closing the panel |
|---|---|
| Bad (no focus management) | Silence — focus is on <body>, user must Tab to re-orient |
| Good (focus restored) | “Settings, button” — user is right back where they started |
Roving Tabindex
Roving Tabindex Toolbar vs. All-Tabbable Toolbar
Inaccessible
<!-- All buttons have tabindex="0" — 4 Tab stops to traverse -->
<div aria-label="Text formatting">
<button tabindex="0">B</button>
<button tabindex="0">I</button>
<button tabindex="0">U</button>
<button tabindex="0">S</button>
</div>
<!-- User must press Tab 4 times to move past the toolbar -->Live Preview
Tab through all 4 buttons to exit the toolbar — each is a separate Tab stop.
Accessible
<!-- Roving tabindex — one Tab stop, arrow keys navigate within -->
<div role="toolbar" aria-label="Text formatting" onkeydown="
var btns = Array.from(this.querySelectorAll('button'));
var idx = btns.indexOf(document.activeElement);
if (event.key === 'ArrowRight') {
event.preventDefault();
var next = (idx + 1) % btns.length;
btns.forEach(function(b) { b.setAttribute('tabindex', '-1'); });
btns[next].setAttribute('tabindex', '0');
btns[next].focus();
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
var prev = (idx - 1 + btns.length) % btns.length;
btns.forEach(function(b) { b.setAttribute('tabindex', '-1'); });
btns[prev].setAttribute('tabindex', '0');
btns[prev].focus();
}
">
<button tabindex="0" aria-pressed="false">B</button>
<button tabindex="-1" aria-pressed="false">I</button>
<button tabindex="-1" aria-pressed="false">U</button>
<button tabindex="-1" aria-pressed="false">S</button>
</div>Live Preview
One Tab stop. Arrow keys move between buttons. Tab exits the toolbar.
What’s wrong with the bad example?
- Every button has
tabindex="0", meaning each one is a separate Tab stop - A keyboard user must press Tab 4 times just to move past the toolbar to the next part of the page
- In a complex UI with multiple toolbars, this quickly makes keyboard navigation unbearable
- There is no
role="toolbar"to identify the group semantically - No arrow key navigation — this breaks the expected keyboard pattern for toolbar widgets
What the screen reader announces:
| Version | Announcement on Tab into toolbar |
|---|---|
| Bad (all tabbable) | “B, button” — no toolbar context, each button is a separate stop |
| Good (roving tabindex) | “Text formatting, toolbar. B, toggle button, not pressed” |
| Good (Arrow Right) | “I, toggle button, not pressed” — moves within the group |