AccessibilityGENERALPLATFORM-SPECIFICBROWSER-SPECIFIC

Focus Management

Focus is a single pointer into the document that the browser maintains for you — until your application replaces the DOM underneath it. Then it becomes yours to move, contain and restore.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Where is focus right now, where should it go next, and who is responsible for putting it there?

The user intent

A person operating the page without a pointer needs to know where they are after every action: after opening a dialog, after closing it, after deleting a row, after navigating to a new route.

The obvious build

The browser handles focus. Clicking moves it, Tab moves it, and as long as we do not do anything strange it will look after itself.

Why it breaks

The browser handles focus for document navigation. A client-side route change is not a navigation: the DOM is replaced, focus lands nowhere, and the next Tab starts again from the top of the page (Client-Side Routing).

How it breaks in a real browser
  • The browser handles focus for document navigation. A client-side route change is not a navigation: the DOM is replaced, focus lands nowhere, and the next Tab starts again from the top of the page (Client-Side Routing).
  • Removing the focused element sends focus to body. A screen-reader user loses their position entirely, with no announcement that anything happened, and Tab restarts from the beginning of the document.
  • A dialog rendered as a div leaves focus behind it. The user Tabs out of the dialog and operates the page underneath — controls they cannot see, in a context they believe is blocked (Accessible Component Patterns).
  • An off-canvas drawer hidden with transform: translateX(-100%) is still in the accessibility tree and still focusable. Tab walks into it and focus disappears off the side of the screen.
  • outline: none in a reset stylesheet removes the only orientation cue a keyboard user has. Everything remains operable, and nobody can see where they are.
  • Positive tabindex values reorder the entire document's focus sequence — every element with a positive value comes before every element without one, across the whole page, including third-party widgets.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • There is exactly one focused element per document (document.activeElement), and its default value is body. Focus is a global singleton, which is why two components independently deciding to move it produce a fight rather than a merge.
  • tabindex has three meanings. 0 puts the element in the tab order at its DOM position. -1 makes it programmatically focusable via .focus() but not reachable by Tab — the value you want for headings, dialog containers and roving-tabindex items. Any positive value hoists the element to the front of the document's focus order and is almost always a bug.
  • Focus does not move to hidden elements: display: none and visibility: hidden are not focusable, and .focus() on them silently does nothing. Everything else — opacity: 0, clipped, translated off-screen — is focusable.
  • inert removes an entire subtree from focus, from clicks and from the accessibility tree, which is exactly what a modal needs for the rest of the page. <dialog> opened with showModal() applies it to everything else automatically, and also gives you the top layer, a ::backdrop, and Escape-to-close.
  • :focus matches whenever the element is focused, including after a mouse click. :focus-visible matches only when the browser heuristically decides the user would benefit from seeing the indicator — keyboard interaction, generally not mouse. That heuristic is why "the ring appears on click and designers hate it" stopped being a reason to remove the ring.
  • Calling .focus() scrolls the element into view unless you pass { preventScroll: true }, and the scroll happens synchronously, forcing layout.

What this makes the browser do

And which of it is avoidable.

  • Every focus change is a style invalidation on two elements plus a scroll-into-view that reads layout. In a tight arrow-key loop this is the per-keystroke cost (Layout Thrashing).
  • .focus() on an element inside a collapsed or lazily-rendered subtree forces that content to be rendered and laid out immediately.
  • Applying inert to a large subtree removes it from the accessibility tree, which means rebuilding those platform objects when it is removed again — cheap for a modal, noticeable if toggled per keystroke.
  • Avoidable work: calling .focus() in a loop or on every render. Focus is idempotent in effect and not in cost — each call re-runs the scroll and the invalidation.

The moments focus must move

A single-page application replaces the DOM without navigating, and the browser has no way to know that a context change happened. Every one of the moments below is a place where a server-rendered page would have moved focus for you and a client-rendered one does not.

The route change is the one most often missed, because nothing looks wrong: the new view renders, the URL updates, and a mouse user sees exactly what they expected. Focus, meanwhile, is still on the link in the navigation that no longer exists, or has fallen to body, and a screen-reader user has been given no indication that the page changed at all.

accessibility specClient-side route changeFocus contract for a client-side route change

semantics A single h1 per view, carrying tabindex="-1" so it can receive programmatic focus without joining the tab order. A main landmark wrapping the view content, and a skip link as the first tab stop of the document.

Tab (immediately after navigation)Must continue from the top of the new view, not from wherever the removed link used to be.
Browser Back / ForwardSame treatment as a forward navigation — the view changed, so focus moves and scroll position is restored (History and Navigation).
EscapeIf the navigation came from a menu or command palette, that surface closes first and does not steal the focus move.
Focus
  • After the new view commits, move focus to its h1 (or the main element) with focus({ preventScroll: true }) and let scroll restoration handle the viewport separately (Scroll Restoration).
  • Move focus after the content has rendered, not when the navigation starts — focusing a loading skeleton announces the skeleton.
  • If the route renders an error or an empty state, focus that instead. The user asked to go somewhere; tell them where they landed (Loading, Error, Empty — The States You Did Not Render).
  • Never leave focus on the link that triggered the navigation when that link no longer exists in the new view.
Announces
  • The focus move announces the heading, which names the new view — usually sufficient, and better than a live region because it also relocates the user.
  • If the view arrives asynchronously, announce the loading state once in a polite region and let the focus move announce the arrival (Route Loading Boundaries).
  • The document title should also change, because some screen readers announce it on history navigation and browser history depends on it.

usually broken by Doing both: moving focus to the heading *and* firing a live-region announcement of the same route name. The two race, the screen reader may read the region and then the heading, and the user hears the page name twice with no clue which one reflects where they are.

tabindex has three values and two of them are useful

The attribute looks like a single knob with a numeric range and it is really three separate features sharing a syntax. Confusing them produces two of the most common defects in this lesson: elements that cannot be focused programmatically when they need to be, and documents whose tab order has been globally rearranged by one component.

The rule for positive values is worth stating flatly: they reorder focus navigation for the entire document, they compose badly with anything you did not write, and any tab order they fix could have been fixed by moving the element in the DOM. Treat a positive tabindex in a code review as a defect until proven otherwise.

ValueReachable with TabFocusable with `.focus()`Use it forFailure it invites
(absent)Only if natively focusableOnly if natively focusableEverything. This is the default and it is usually right.Assuming a div is focusable because it has a click handler.
0Yes, at its DOM positionYesA custom control that genuinely needs to be a tab stop, and the currently active item in a roving-tabindex widget.Adding it to a div and believing that made the control accessible (Semantics Before ARIA).
-1NoYesHeadings and containers you focus after a context change; inactive items in a composite widget; a dialog wrapper.Leaving it on every item of a composite so the widget vanishes from the tab order entirely.
PositiveYes, before every 0 in the documentYesNothing. There is no case where this is the best available tool.One component silently reordering the whole page, including third-party embeds and the browser's own chrome ordering assumptions.
Restoring focus, including when the target is gone
1function useReturnFocus(open: boolean) {
2 const trigger = useRef<HTMLElement | null>(null)
3
4 useEffect(() => {
5 if (open) {
6 // Capture at open time. Reading activeElement at close time
7 // returns something inside the dialog that is about to unmount.
8 trigger.current = document.activeElement as HTMLElement | null
9 return
10 }
11 const el = trigger.current
12 trigger.current = null
13 if (!el) return
14
15 // .focus() on a detached node silently does nothing, which is why
16 // this bug survives review: no error, no warning, focus on <body>.
17 if (el.isConnected) el.focus()
18 else document.getElementById('results-heading')?.focus()
19 }, [open])
20}

The isConnected check is the whole point. The trigger is a row action button, the dialog deleted the row, and the element you carefully saved a reference to no longer exists — so the fallback anchor has to be part of the design, not an afterthought.

Rings you can actually see

The focus indicator is removed more often than any other accessibility feature, and almost always for the same reason: it appeared after a mouse click, on an element where it looked wrong, and the quickest fix in the stylesheet was to delete it everywhere. :focus-visible exists precisely to solve that, and it has been available across engines long enough to be the default approach.

The replacement also has to be visible. An outline that is a slightly different shade of the background is the same as no outline. It has to work on hover states, on selected rows, on coloured buttons, in dark mode and in forced-colours mode, where your colours are replaced by the system's (Contrast, Colour and Motion).

Removing the ring, and replacing it
The reset that ships everywhere
*:focus {
  outline: none;
}
/* Everything is still operable by keyboard.
   Nothing tells the user where they are. */
Indicator on a keyboard interaction, designed to be seen
/* Only style what you actually replace. */
:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: inherit;
}

/* Mouse clicks on a button no longer show a ring,
   which was the original complaint — solved without
   taking the indicator away from keyboard users. */
:focus:not(:focus-visible) {
  outline: none;
}

/* In forced-colors mode the system palette replaces yours.
   Opt back into a colour the system guarantees is visible. */
@media (forced-colors: active) {
  :focus-visible { outline-color: Highlight; }
}

outline does not participate in layout, so it cannot shift the page the way a border change does; outline-offset keeps it clear of the element's own edges; and :focus-visible distinguishes the case that motivated the removal from the case that depends on the indicator. The forced-colours block matters because a custom-property colour is discarded there and the ring would otherwise disappear on exactly the platform whose users need it most.

How to build it

Most important first.

  • Move focus whenever the context changes, and only then: opening a dialog or drawer, revealing new content the user asked for, submitting a form with errors, changing route.
  • Restore focus to the element that started the interaction when it ends. Keep the reference at the moment you open, not at the moment you close.
  • When the restore target no longer exists — the row was deleted, the item was archived — move focus to the nearest sensible anchor: the next row, the list container with tabindex="-1", or the heading of the section. Never let it fall to body.
  • On route change, move focus to a tabindex="-1" heading or a skip target at the top of the new view, and let the focus move do the announcing rather than adding a live region on top of it (Live Regions and Announcement).
  • Use <dialog> with showModal() where it is available: containment, the top layer, Escape, and background inertness are then the browser's job rather than yours.
  • Style the focus indicator, do not delete it. :focus-visible with a visible outline and outline-offset costs nothing in layout, follows border radius, and can be designed to match the brand.
  • Never use a positive tabindex. If the tab order is wrong, the DOM order is wrong.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Focus is orientation. For a keyboard or screen-reader user it answers "where am I", and losing it is equivalent to the page scrolling somewhere random with no indication why.
  • A visible focus indicator is a requirement, not a design preference, and it must be visible against every background it can land on — including hover states, selected rows and dark mode (Contrast, Colour and Motion).
  • Moving focus is itself an announcement: the screen reader reads the newly focused element. This is usually a better mechanism than a live region, because it also puts the user in the right place (Live Regions and Announcement).
  • Do not move focus without a user action. Focus that jumps on a timer, on a background data load, or on a validation pass the user did not trigger is disorienting and can lose typed input.
  • Screen magnifier users follow the focus rectangle. A focus move that scrolls the page but leaves the indicator invisible strands them completely.

What can go wrong

Failure modes
  • Focus restored to a detached node. .focus() on an element that is no longer in the document does nothing at all, and the failure is silent — this is the single most common focus bug in single-page applications.
  • Focus set before the element exists, because the framework has not committed the DOM yet. In React the fix is a ref plus an effect, or the autoFocus-equivalent the component library provides — not a setTimeout guess.
  • Two focus calls in the same task, one from a dialog opening and one from a data load completing. The last one wins, non-deterministically from the user's point of view.
  • A focus trap with no exit: the wrap-around logic runs before Escape is handled, so the dialog contains focus and cannot be closed by keyboard.
  • The mitigation failing: an inert background applied on open and never removed on an error path, leaving the whole page unoperable with no dialog on screen.
  • Scroll jump: focusing an element far down a long page yanks the viewport, and with a sticky header the focused element ends up underneath it — invisible but focused. scroll-margin-top fixes the second half.
What can arrive out of order
  • Focus versus render commit: .focus() called in the same task as the state update that creates the element runs before the element exists. The element must be committed first.
  • Focus versus announcement: moving focus in the same frame as a live-region update makes the two race, and which one the screen reader keeps varies by product (Live Regions and Announcement).
  • Focus versus data: an async load that re-renders a list while the user is arrowing through it moves focus out from under them unless the selection is keyed to identity rather than index (Reconciliation and Keys).
  • Two components restoring focus at once — a closing dialog and a completing request — with the winner decided by task ordering rather than by intent.
Security
  • Focus is a legitimate hijacking surface: a script that moves focus into an element it controls can capture the next keystrokes, which is one reason autofocus on cross-origin content is restricted (Third-Party Scripts and the Supply Chain).
  • Clickjacking defences care about focus too — a transparent overlay that receives clicks also receives focus, and a keyboard user can activate what they cannot see (Clickjacking and Framing).
  • Restoring focus after a destructive confirmation is a usability matter, not a control. The confirmation itself proves nothing to the server (Authorization-Aware UI).
Misreads
  • ":focus-visible hides the ring, which is what we wanted." It shows the ring when the user needs it and hides it after a mouse click. Removing the ring outright is a different and much worse change.
  • "outline: none is fine because we style :hover." Hover is a pointer state. It tells a keyboard user nothing.
  • "Positive tabindex fixes our tab order." It reorders the whole document, breaks third-party widgets, and the underlying problem is DOM order.
  • "Focus management is only for modals." Route changes, deletions, expanding sections, validation errors and asynchronous content all move the ground under the user.
  • "autofocus is the same as managing focus." It fires once, on load, and does nothing for every subsequent context change.

Measuring it, and what changes in the field

How you would see this
  • Run document.activeElement in the console after each step of a flow — after open, after close, after delete, after navigation. If it is ever <body>, that is the bug.
  • DevTools: Chrome's Elements pane can force :focus and :focus-visible; Firefox's Accessibility panel highlights the currently focused node.
  • A focusin listener on document that logs event.target turns an entire session into a focus trace, which is the fastest way to find the moment focus was lost.
  • End-to-end tests can assert the focused element after each interaction; this is the only way focus behaviour survives a refactor (End-to-End Testing).
Slow device, slow network, large data, old tab
  • On a slow device, the gap between "the route changed" and "the new view has committed" widens, and a focus call timed against the old timing lands on nothing (Long Tasks).
  • With a screen reader in browse mode, the reading cursor and the focus position are two different things — moving focus moves both, but arrowing through content moves only the reading cursor.
  • On mobile, moving focus into a text input opens the on-screen keyboard and resizes the viewport, which is intrusive if the user did not ask for it.
  • In a long-lived tab, focus references held for restore are a retention path: keeping a DOM node in a closure across route changes keeps its whole subtree alive (Memory Leaks).
What this costs
  • Explicit focus management is code that has no visual output and is therefore easy to delete in a refactor. It needs tests for the same reason.
  • <dialog> gives you containment and inertness for free, at the cost of the top layer's stacking and styling rules, which do not always cooperate with an existing design system's z-index conventions (Positioning and Stacking Contexts).
  • A focus indicator designed to be visible on every background is a real constraint on a visual design, and the alternative — a subtle ring that vanishes on half the page — is the same as having none.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALOne focused element per document, the three meanings of tabindex, and the rule that hidden elements are not focusable are specified and consistent across engines.
  • PLATFORM-SPECIFICWhat a focus move announces depends on the screen reader: VoiceOver on macOS reads the focused element and often its containing group, NVDA and JAWS on Windows read the element and switch modes depending on its role, and VoiceOver on iOS moves its own cursor which does not always follow programmatic focus at all — an iOS-only failure that Windows testing will never surface.
  • BROWSER-SPECIFICThe :focus-visible heuristic is per-browser: all three engines show the indicator for keyboard interaction, but they disagree about mouse clicks on text inputs and about focus moved programmatically after a pointer event. <dialog>, inert and the top layer also reached the three engines at different times, so a support floor decides whether they are available to you.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

DSAdfs
Domains that do not exist yet
  • Testing & Reliability Engineering — asserting focus in tests: capturing document.activeElement after each step, and treating "focus is on body" as a hard failure rather than a warning.