Skip to Content
Component ExamplesFocus Management

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:

Key requirements:

  • tabindex="0": adds an element to the natural Tab order
  • tabindex="-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
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

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:

VersionAfter 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:

VersionAnnouncement 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

Resources