Focus Management: The Complete Accessibility Guide
Focus is the keyboard user's cursor. This guide covers every part of managing it well — tabindex, :focus-visible, moving focus programmatically, focus traps, restoration, roving tabindex, skip links, and route-change focus — with copy-ready code mapped to WCAG 2.2.
Why Focus Management Matters
A sighted mouse user can look anywhere and click anything. A person using a keyboard, a switch device, or a screen reader can only interact with the one element that currently has focus. Focus is their cursor and their sense of place. When it moves logically and stays visible, the interface is navigable. When it jumps unexpectedly, disappears, or gets stuck, the interface becomes unusable — no matter how good it looks.
Most focus bugs come from dynamic behavior that native HTML never had to handle: a dialog opens and focus stays behind it, a single page app changes route without resetting focus, a list item is deleted and focus falls back to <body>, or a designer removes the focus outline because it “looks cluttered.” This guide works through each situation with a concrete fix, and ties every technique back to the WCAG success criteria it satisfies.
The WCAG 2.2 Criteria Focus Management Covers
| Criterion | Level | What it requires |
|---|---|---|
| 2.1.1 Keyboard | A | Every interactive element is focusable and operable by keyboard. |
| 2.1.2 No Keyboard Trap | A | Focus can always leave a component using the keyboard alone. |
| 2.4.3 Focus Order | A | Focus order preserves meaning and operability (logical sequence). |
| 2.4.7 Focus Visible | AA | The keyboard focus indicator is always visible. |
| 2.4.11 Focus Not Obscured (Min) | AA | The focused element is not entirely hidden by other content. |
| 3.2.1 On Focus | A | Moving focus to a component does not trigger an unexpected change of context. |
WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum) at AA — a common failure with sticky headers and cookie banners. For the full picture see the WCAG 2.2 Level AA requirements and the interactive WCAG 2.2 checklist.
1. Focus Order & the tabindex Attribute
The Tab order follows the DOM order by default. That is the single most important thing to get right: if your source order matches your visual reading order, focus order is correct for free. CSS that visually reorders content (flex-direction, order, grid placement) does not change focus order, so a mismatch between visual and DOM order is a classic 2.4.3 failure. The tabindex attribute has exactly three meaningful uses:
<!-- tabindex="0": add a CUSTOM element to the natural Tab order -->
<div role="button" tabindex="0" onkeydown="/* handle Enter/Space */">
Custom control
</div>
<!-- tabindex="-1": focusable ONLY via script, not in the Tab sequence -->
<h1 tabindex="-1">Page title we move focus to on route change</h1>
<!-- tabindex="1+": AVOID. Overrides DOM order for the whole page. -->
<input tabindex="3"> <!-- fragile, surprising, hard to maintain -->Prefer native interactive elements — <button>, <a href>, <input>, <select> — which are focusable and keyboard operable without any tabindex at all. Only reach for tabindex={0} when you have genuinely built a custom control, and add the matching keyboard handlers. See the keyboard accessibility guide for the full interaction model.
2. Keep Focus Visible with :focus-visible
The most common accessibility regression on the web is outline: none. Designers remove the focus ring because it appears on mouse click too, and it never comes back for keyboard users. The fix is :focus-visible, which the browser applies only when a visible indicator is warranted — effectively keyboard focus, not a plain mouse click.
/* Never do this — hides focus from keyboard users everywhere */
:focus { outline: none; }
/* Do this — a strong ring for keyboard users, quiet on mouse click */
:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
border-radius: 2px;
}
/* Optional: suppress the ring ONLY on mouse focus, keep it for keyboard */
:focus:not(:focus-visible) { outline: none; }Make the indicator obvious: aim for a contrast ratio of at least 3:1 against adjacent colors, and give it enough thickness and offset to read clearly. A custom ring must be at least as visible as the browser default it replaces. This satisfies 2.4.7 Focus Visible. Check your indicator colors with the contrast checker.
3. Moving Focus Programmatically
When the interface changes in a way the user did not directly trigger a focus for — new content appears, a step completes, a view swaps — you often need to move focus deliberately. Use element.focus(), and reach for preventScroll to avoid a jarring jump when the target is already on screen.
// Move focus to a heading or region after inserting content.
// The target needs tabindex="-1" so it can receive programmatic focus.
function focusTarget(el) {
if (!el) return
el.focus({ preventScroll: true }) // don't yank the viewport
// If the target is off-screen, bring it into view intentionally:
el.scrollIntoView({ block: "start", behavior: "smooth" })
}
// Example: after loading a new panel of results
const heading = document.querySelector("#results-heading")
focusTarget(heading)Two rules keep this safe. First, only move focus in response to a user action or a change the user asked for — moving focus out from under someone as they read is disorienting and can fail 3.2.1 On Focus. Second, never focus something the user cannot see. If you must announce a change without moving focus, use an aria-live region instead — covered in the ARIA reference.
4. Focus Traps for Modal Dialogs
A modal takes over the screen, so while it is open, keyboard focus must stay inside it — Tab from the last control wraps to the first, Shift+Tab from the first wraps to the last, and the rest of the page is inert. This is the one place an intentional focus trap is correct. The native <dialog> element with showModal() implements the whole pattern for you, including Escape-to-close.
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete this project?</h2>
<p>This action cannot be undone.</p>
<button value="cancel">Cancel</button>
<button value="delete">Delete</button>
</dialog>
<script>
const dialog = document.getElementById("confirm")
// showModal() traps focus, makes the page inert, enables Escape,
// and moves focus into the dialog automatically.
openButton.addEventListener("click", () => dialog.showModal())
</script>If you build a custom overlay instead, you must add role="dialog", aria-modal="true", an accessible name, a focus trap, Escape-to-close, and focus restoration yourself. Keep the trap escapable — a trap with no keyboard exit fails 2.1.2 No Keyboard Trap. React developers can see the ref-based version in the React accessibility guide.
5. Restore Focus When Things Close or Disappear
Two situations demand that you put focus somewhere sensible. First, when a dialog, menu, or popover closes, focus must return to the control that opened it — otherwise it falls to <body> and the user restarts from the top. Second, when you remove the currently focused element (deleting a list row, dismissing a card), move focus to a neighbor before the element leaves the DOM.
// Pattern A: remember the opener, restore on close
let opener = null
function openMenu(trigger) {
opener = trigger // the button that opened the menu
menu.hidden = false
menu.querySelector("[role=menuitem]").focus()
}
function closeMenu() {
menu.hidden = true
opener?.focus() // send focus back where it came from
}
// Pattern B: deleting the focused item — focus the next best thing first
function removeRow(row) {
const next = row.nextElementSibling || row.previousElementSibling
;(next || row.closest("[data-list]")).focus() // container is tabindex=-1
row.remove()
}For deletions, the container that receives fallback focus should have tabindex={-1} and, ideally, an aria-live region announcing what was removed. This keeps the focus order coherent (2.4.3) and prevents the silent “where did I go?” moment that derails screen reader users.
6. Roving tabindex for Composite Widgets
Tabs, toolbars, menus, and radio-style groups should be a single Tab stop — you Tab into the widget, then arrow keys move between its items, and Tab moves on to the next widget. Roving tabindex is the standard technique: exactly one item is tabindex="0" at a time and the rest are tabindex="-1".
// Toolbar with roving tabindex: only the active button is tabbable.
const buttons = [...toolbar.querySelectorAll("button")]
let active = 0
buttons.forEach((b, i) => (b.tabIndex = i === 0 ? 0 : -1))
toolbar.addEventListener("keydown", (e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return
buttons[active].tabIndex = -1 // demote the old item
active =
e.key === "ArrowRight"
? (active + 1) % buttons.length
: (active - 1 + buttons.length) % buttons.length
buttons[active].tabIndex = 0 // promote the new item
buttons[active].focus() // move DOM focus with it
})The alternative, aria-activedescendant, keeps DOM focus on the container and points at a virtually-active child by id — better when focus must stay on a text input, as in a combobox. Follow the exact key bindings in the ARIA Authoring Practices patterns and confirm the roles and states in our ARIA roles & attributes reference.
7. Skip Links & Focus on Navigation
A skip link lets keyboard users jump past a repeated header straight to the main content. It is the first focusable element on the page, visually hidden until focused, and it moves focus to a <main> target. In a single page app, you also need to move focus on route change, because swapping content client-side does not reset focus the way a full page load does.
<!-- Skip link: first in the DOM, revealed on focus -->
<a href="#main" class="skip-link">Skip to main content</a>
...
<main id="main" tabindex="-1">…</main>
<style>
.skip-link {
position: absolute;
left: -9999px; /* off-screen until focused */
}
.skip-link:focus {
left: 1rem; top: 1rem; /* pull into view on focus */
z-index: 1000;
}
</style>On client-side navigation, move focus to the new view's <h1> (with tabindex={-1}) or its <main> region so keyboard and screen reader users are not stranded on a stale link. This is a 2.4.3 requirement; the framework-specific version is in the React accessibility guide.
8. Keep Focus Unobscured (WCAG 2.2)
New in WCAG 2.2, 2.4.11 Focus Not Obscured (Minimum) requires that when an element receives focus, it is not entirely hidden by author-created content. The usual culprit is a sticky header or footer: you Tab to a field near the top of the viewport and the fixed header scrolls over it, so you cannot see what you are typing into. Reserve space with scroll-margin-top.
/* Sticky header 64px tall */
header { position: sticky; top: 0; height: 64px; }
/* When any element is scrolled to on focus, leave room for the header
so it is never hidden underneath it. */
:target,
[tabindex],
a, button, input, select, textarea {
scroll-margin-top: 80px; /* header height + a little breathing room */
}Test it the way a user hits it: Tab through the whole page while slowly scrolling, and watch for any focused control that disappears behind fixed content. Cookie banners, chat widgets, and “back to top” buttons are frequent offenders. The stricter AAA version, 2.4.13 Focus Appearance, additionally sets a minimum size and contrast for the indicator itself.
Focus Order Rules
- DOM order = reading order; don't reorder with CSS alone.
- Never use a positive
tabindex. - Use
tabindex={-1}for programmatic targets only. - One Tab stop per composite widget (roving
tabindex). - Restore focus to the trigger when overlays close.
More in the keyboard accessibility guide.
Focus Visibility Rules
- Style
:focus-visible; never bareoutline: none. - Indicator contrast at least 3:1 against neighbors.
- Keep focus out from under sticky headers (2.4.11).
- Focus a visible target; announce silent changes with live regions.
- Don't change context just because focus arrives (3.2.1).
Verify with the screen reader testing guide.
How to Test Focus Management
Focus is one of the few things automated scanners can only partially check — they flag missing indicators and positive tabindex, but focus order and restoration need a human at the keyboard. Run this pass on every interactive view:
- Tab through the whole page. Focus should move in reading order, and the focused element should always be clearly visible.
- Open and close every overlay. Focus enters the dialog, is trapped inside, and returns to the trigger on close (Escape and the close button both).
- Operate composite widgets. Tab reaches the widget once; arrow keys move within it; Tab moves on.
- Trigger dynamic changes. Delete a row, load results, submit a form — confirm focus lands somewhere sensible, not on
<body>. - Scroll while tabbing. No focused control hides behind a sticky header or banner.
Layer in automated help: axe-core flags missing focus indicators and positive tabindex, and you can assert focus in end-to-end tests (expect(page.locator(":focus")).toHaveText(...)). See automated vs manual testing for where each fits, then scan a live page with the URL accessibility auditor.
Common Focus Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| outline: none on :focus (or globally on *). | Removes the only cue keyboard users have for where they are (WCAG 2.4.7). | Style :focus-visible with a visible ring; never remove without a replacement. |
| Positive tabindex values (tabindex="1", "2"…). | Overrides DOM order page-wide, creating fragile, surprising jumps (2.4.3). | Order the DOM logically; use only tabindex="0" or "-1". |
| Dialog opens but focus stays on the page behind it. | Keyboard users tab through hidden content; screen readers get lost (2.4.3). | Move focus into the dialog on open; trap it while open. |
| Menu or widget with no keyboard way out. | Accidental keyboard trap — users are stuck (2.1.2 No Keyboard Trap). | Ensure Tab/Escape always releases focus; only modals trap intentionally. |
| Deleting the focused element without moving focus. | Focus falls back to <body>; the user loses their place entirely. | Move focus to a sensible neighbor before removing the element. |
| Sticky header covering the focused field on scroll. | Focused element is hidden behind fixed content (2.4.11 Focus Not Obscured). | Add scroll-margin-top or offset so focus stays visible below the header. |
Focus Management Checklist
- Logical order. Tab order matches the visual reading order; no positive
tabindex. - Always visible. Every focusable element shows a clear
:focus-visibleindicator with 3:1 contrast. - Overlays trap & restore. Modals move focus in, trap it, close on Escape, and return focus to the trigger.
- Widgets have one Tab stop. Roving
tabindexoraria-activedescendantfor tabs, menus, toolbars. - Navigation moves focus. Skip link to
<main>; route changes focus the new heading. - Deletions relocate focus. Removing the focused element moves focus to a neighbor first.
- Nothing obscures focus. Sticky headers and banners never cover the focused control (
scroll-margin-top).
Work through the full WCAG 2.2 checklist to see focus in the context of every other requirement.
Find Focus Issues on Your Site
Scan any page with our free axe-core-powered auditor to catch missing focus indicators, positive tabindex, and unlabeled controls — then run the manual keyboard pass above.
Frequently Asked Questions
What is focus management in web accessibility?▾
Focus management is the practice of controlling which element on a page is currently focused — the element that receives keyboard input and is highlighted by a focus indicator. For keyboard and screen reader users, focus is their cursor: it is how they know where they are and what they can interact with. Good focus management means the Tab order follows a logical reading sequence, the focused element is always visible, and focus is moved deliberately when the interface changes — a dialog opens, a route changes, content is inserted or removed. When focus is mishandled, users get silently stranded, lose their place, or are dumped back at the top of the page. It underpins WCAG 2.4.3 Focus Order, 2.4.7 Focus Visible, and 2.4.11 Focus Not Obscured.
What is the difference between tabindex 0, -1, and a positive value?▾
tabindex="0" inserts an element into the natural Tab order at its DOM position and makes it focusable — use it to make a genuinely custom interactive element reachable by keyboard. tabindex="-1" makes an element focusable only programmatically (via element.focus()) but keeps it out of the Tab sequence — use it for headings, containers, or off-screen targets you want to move focus to but not tab to. A positive tabindex (1, 2, 3…) forces an explicit Tab order that overrides DOM order for the whole page; this is almost always a mistake because it is fragile, hard to maintain, and creates surprising jumps. The rule of thumb: never use a positive tabindex, use 0 sparingly on real custom controls, and use -1 for programmatic focus targets.
What is a focus trap and when should I use one?▾
A focus trap keeps keyboard focus cycling inside a specific region so that Tab and Shift+Tab cannot move focus to content behind it. It is required for modal dialogs: while a modal is open, the rest of the page is inert, so focus must stay inside the dialog until it closes. The key distinction is intentional vs accidental. An intentional, escapable trap (a modal you can close with Escape) is correct and satisfies WCAG. An accidental trap — a widget that captures focus with no keyboard way out — fails WCAG 2.1.2 No Keyboard Trap. The native <dialog> element with showModal() implements a compliant, escapable focus trap for you, including Escape-to-close.
What is :focus-visible and how is it different from :focus?▾
The :focus pseudo-class matches any focused element, including one focused by a mouse click, which is why removing outlines with :focus (or worse, outline: none globally) is so damaging — it hides the indicator from keyboard users too. The :focus-visible pseudo-class matches only when the browser heuristically determines a visible focus indicator is needed, which is essentially keyboard focus and not a plain mouse click. This lets you show a strong focus ring for keyboard users while not showing one on mouse click, giving you the visual polish designers often want without failing WCAG 2.4.7 Focus Visible. Always style :focus-visible, and never remove a focus indicator without providing a clearly visible replacement.
How do I move focus without the page scrolling abruptly?▾
Call element.focus({ preventScroll: true }). By default, focusing an element scrolls it into view, which can cause a jarring jump when you move focus to something already visible or near the top. preventScroll: true moves focus without scrolling, letting you control scrolling separately (or not at all). This is useful when focusing a route heading, a skip-link target, or a status region. Be careful, though: if the focus target is off-screen, you must ensure the user can still see where focus went — never move focus to something invisible without also bringing it into view.
When should I use roving tabindex instead of aria-activedescendant?▾
Both are techniques for making a composite widget — tabs, a menu, a listbox, a grid — expose a single Tab stop while arrow keys move between its items. With roving tabindex, exactly one item has tabindex="0" (the rest are tabindex="-1") and DOM focus actually moves as arrow keys are pressed; you update tabindex and call focus() on the newly active item. With aria-activedescendant, DOM focus stays on the container and you set aria-activedescendant to the id of the virtually-active child. Roving tabindex is simpler and works well for most menus, toolbars, and tab sets. aria-activedescendant suits cases where focus must remain on an input — such as a combobox where the text field keeps focus while options are navigated.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences