Focus Management
"When navigation happens without page reloads, screen readers don't automatically announce changes, and when DOM updates happen dynamically, focus can get lost." — Deque, Accessibility Tips in Single-Page Applications
Focus management is the discipline of ensuring keyboard and screen reader users always know where they are, can reach what they need, and never get stranded. Every modal, route change, deletion, and insertion is a potential focus loss — and focus loss means a broken experience.
1. Modal Focus Trapping
When a modal opens, focus must stay inside it. Background content must become unreachable.
Preferred: the inert attribute
Apply inert to sibling containers of the modal. The browser removes the inert subtree from the accessibility tree, prevents focus, and blocks hit-testing.
<div id="app" inert><!-- page content --></div>
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
<!-- modal content -->
</div>
For native <dialog> opened via showModal(), the browser handles inertness automatically — no manual inert attribute needed.
Browser support: Chrome 102+, Safari 15.5+, Firefox 112+.
Source: WHATWG HTML Spec — The inert attribute
Fallback: sentinel focus trap
Place invisible focusable sentinels at the start and end of the modal. When focus reaches a sentinel, redirect to the opposite end. This requires maintaining a list of focusable elements:
a[href], button:not([disabled]), input:not([disabled]),
select:not([disabled]), textarea:not([disabled]),
[tabindex]:not([tabindex="-1"]), audio[controls],
video[controls], details > summary,
[contenteditable]:not([contenteditable="false"])
Where to place initial focus
There is no single correct answer — it depends on context (Adrian Roselli, Dialog Focus in Screen Readers):
- Small confirmation dialogs → Focus the cancel/close button. Never the destructive action.
- Large form dialogs → Focus the heading or first input field.
- General rule → Never place initial focus on a destructive action button.
See: references/modal-focus-trapping.md
2. Focus Restoration
When a modal closes, focus must return to the element that triggered it.
Standard pattern
Store a reference to document.activeElement before opening the modal. On close, call .focus() on that stored reference.
// Before opening
const trigger = document.activeElement;
// On close
trigger?.focus();
Edge case: trigger removed from DOM
If the triggering element was removed while the modal was open (e.g., the modal confirmed a deletion that removes the trigger's row), attempting to focus it fails silently and focus falls to <body>, stranding keyboard users.
Fallback chain (in order):
- Previous sibling's equivalent control
- The containing element
<main>or the skip-nav target- Never let focus fall to
<body>
"When the close button is hidden the body element will be focused by default, which will force keyboard users to step through the page manually to find where they left off." — Medium, Stop Breaking Modal Accessibility
Source: Bootstrap Issue #12364
3. SPA Route Changes
Traditional page loads reset focus to the document top and trigger screen reader announcements. Client-side navigation does neither. You must fix both.
Focus the <h1>
On each route change, move focus to the page's <h1>. This mimics traditional page load behavior and causes screen readers to announce the new page.
<h1 tabindex="-1" id="page-title">Dashboard</h1>
// After route change completes
document.querySelector('h1')?.focus();
The tabindex="-1" makes the heading programmatically focusable without entering the tab order.
Do not focus on initial page load — let users explore from the top naturally.
Source: BBC GEL Technical Guide: Routing
Additional requirements
- Update
document.titleon every route change — screen readers announce title changes. - Use
aria-current="page"on the active navigation link. - Announce the route change via an ARIA live region (used by Next.js and Gatsby):
<div aria-live="assertive" class="sr-only" id="route-announcer"></div>
Source: Deque, Accessibility Tips in Single-Page Applications
See: references/spa-route-changes.md
4. Dynamic Content
Item deletion
When deleting an item from a group (Adrian Roselli, Where to Put Focus When Deleting a Thing):
- Move focus to the previous item's equivalent control (e.g., previous row's delete button).
- If the first item is deleted, focus the container element.
- For single dismissals (notifications, banners), restore focus to what had focus before the item appeared.
Safeguards:
- The element receiving focus must have a useful accessible name — generic "Delete" without context confuses screen reader users.
- Position focus carefully to prevent accidental double-deletions if users press Enter twice.
- Never let focus jump to the page top or fall on non-focusable elements.
Content insertion
- User-initiated (e.g., "Add item" button) → Focus the new content or its first interactive element.
- Asynchronous (e.g., new message in a feed) → Use
aria-liveregions. Do not steal focus — interrupting the user's task with a focus shift is hostile.
Toasts and notifications
- Use
role="status"oraria-live="polite"— announce without stealing focus. - For urgent alerts, use
role="alert"oraria-live="assertive". - Only move focus to a toast if it contains interactive elements requiring immediate action (e.g., an "Undo" button with a time limit).
5. Focus Not Obscured (WCAG 2.2)
SC 2.4.11 — Focus Not Obscured (Minimum, Level AA)
"When a user interface component receives keyboard focus, the component is not entirely hidden due to author-created content."
Sticky headers, footers, banners, and cookie dialogs must not entirely cover focused elements. This is the most common failure.
Source: W3C, Understanding SC 2.4.11
SC 2.4.12 — Focus Not Obscured (Enhanced, Level AAA)
The enhanced level requires complete visibility — no part of the focused component may be hidden.
CSS solution: scroll-padding
Apply scroll-padding to the scroll container to create buffer space around sticky elements:
html, body {
scroll-padding-top: 6rem; /* height of sticky header */
scroll-padding-bottom: 4rem; /* height of sticky footer */
}
For conditionally-visible sticky content (e.g., dismissible cookie banners):
:is(html, body):has(aside.cookie-banner) {
scroll-padding-bottom: 6rem;
}
Prefer scroll-padding over scroll-margin — scroll-margin has browser incompatibilities, particularly in Safari (TetraLogical, Sticky content: focus in view).
Use rem or em units for responsive scaling with zoom/text sizing.
Source: W3C Technique C43
6. Skip Navigation Links
Skip links satisfy WCAG 2.4.1 Bypass Blocks (Level A) — they let keyboard users skip repetitive content.
Implementation
<body>
<a href="#maincontent" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="maincontent" tabindex="-1">
<!-- page content -->
</main>
</body>
The skip link must be the first interactive element in the page.
CSS: hidden until focused
.skip-link {
position: absolute;
top: -40px;
left: 0;
z-index: 100;
padding: 8px;
background: #000;
color: #fff;
}
.skip-link:focus {
top: 0;
}
Do not use display: none, visibility: hidden, or the hidden attribute — these remove the link from keyboard navigation entirely.
SPA considerations
In SPAs, the <main> target must have tabindex="-1" and remain valid across client-side route changes.
Source: WebAIM, Skip Navigation Links
7. Common Mistakes
Removing focus outline globally —
*:focus { outline: none }destroys keyboard accessibility. Go beyond browser defaults, not below them. (Roselli, Keep the Focus Outline)Not restoring focus on modal close — Focus falls to
<body>, stranding keyboard users. (Medium, Stop Breaking Modal Accessibility)Placing initial focus on destructive actions — Opening a delete confirmation and focusing the "Delete" button risks accidental activation. (Roselli, Dialog Focus in Screen Readers)
No focus management on SPA route changes — Screen readers don't announce client-side navigation. (Deque, Accessibility Tips in SPAs)
Using
display: noneon skip links — Removes the link from keyboard navigation, defeating its purpose. (WebAIM, Skip Navigation Links)Ignoring the trigger-removed edge case — Focus falls to
<body>when the modal trigger was removed during the modal's lifetime. (Bootstrap #12364)Relying solely on
:focus-visible— Voice users, touch users, and sighted screen reader users may not see:focus-visiblestyles. Use:focusas the baseline. (Roselli, Where to Put Focus When Deleting a Thing)Not using
scroll-paddingwith sticky headers — Focused elements disappear under sticky content, failing WCAG 2.4.11. (TetraLogical, Sticky content: focus in view)Testing with only one screen reader — Screen readers have significant inconsistencies in dialog focus announcements. (Roselli, Dialog Focus in Screen Readers)
See: references/common-mistakes.md
8. Cross-References
For related patterns, see these companion skills:
a11y-dialog— dialog and alertdialog implementation patternscss-a11y— CSS techniques for accessibility (including focus styles)live-regions— ARIA live region patterns for dynamic announcements
For detailed reference material:
- references/modal-focus-trapping.md — inert, sentinels, and initial focus placement
- references/spa-route-changes.md — route change patterns with framework examples
- references/framework-patterns.md — React, Vue, and Angular focus management
- references/common-mistakes.md — anti-patterns with citations
- references/sources.yaml — provenance for all cited sources