Radix UI - Focus Scope
- 文章發表於
- ...
Problem
Picture this: you're filling out a form in a modal dialog. You press Tab to move to the next field, but suddenly your focus vanishes. The blue outline disappears behind the dimmed overlay. You keep pressing Tab, completely blind to where your cursor has landed. Sound familiar?
Or perhaps you've encountered this: a dropdown menu opens, you start navigating with your keyboard, and somehow you end up focusing on your browser's address bar. The menu stays open, mocking you.
They're symptoms of a fundamental challenge in web development: focus management.
Why Does This Happen?
When a modal opens, something interesting happens or rather, doesn't happen. The browser has no idea that you've just created a "focus boundary" It still sees every focusable element in your entire DOM tree. When you press Tab, the browser dutifully moves to the next focusable element in DOM order. If that element happens to be behind your modal overlay? The browser doesn't care.
This creates a cascade of problems:
- Visual confusion: Focus appears to vanish behind the modal overlay
- Disorientation for sighted keyboard users: They can't see what they're tabbing to
- Screen reader chaos: Assistive technology announces elements that shouldn't be accessible
- Lost context: When the modal closes, users have no idea where they were
The Accessibility Imperative
This isn't just about user experience polish, it's an accessibility requirement. WCAG 2.1 outlines several success criteria that depend on proper focus management:
- : Users must be able to navigate away using standard keyboard commands
- : Navigation order must be logical and intuitive
- : The focused element must be visually apparent
The solution is Focus trapping containing keyboard navigation within a specific region of the page while that region is active.
The Naive Approach (And Why It Falls Apart)
If you were to implement focus trapping from scratch, your first instinct might be straightforward: grab all focusable elements, track the current position, and manually cycle through them.
const focusables = getAllFocusables(); // [el1, el2, el3, ..., el50]const currentIdx = focusables.indexOf(activeElement);const nextIdx = (currentIdx + 1) % focusables.length;focusables[nextIdx].focus();
But implementing getAllFocusables() is harder than it looks. There are too many edge cases:
Attempt 1: Query for tabindex:
container.querySelectorAll('[tabindex="0"]')
This misses native focusable elements like <button> and <a> that don't have an explicit tabindex attribute.
Attempt 2: Add common focusable elements:
container.querySelectorAll('button, [tabindex="0"]')
Attempt 3: The kitchen sink approach:
const selector ='button:not([disabled]), ' +'input:not([disabled]):not([type="hidden"]), ' +'a[href], ' +'area[href], ' +'select:not([disabled]), ' +'textarea:not([disabled]), ' +'[tabindex]:not([tabindex="-1"]), ' +'[contenteditable="true"]';// ... what about iframes? audio/video with controls?// summary elements? custom elements?const elements = container.querySelectorAll(selector);
The Browser Already Knows
Here's the insight that changes everything: the browser already computes focusability at runtime through the tabIndex property (camelCase, not the attribute).
This computed property automatically accounts for:
- Native focusable elements (
button,input,a[href], etc.) - Explicit
tabindexattributes - Disabled states
- The
contenteditableattribute - Platform-specific quirks
The catch? You can't access runtime properties in CSS selectors:
// This is invalid syntaxcontainer.querySelectorAll('[tabIndex >= 0]');
Enter TreeWalker
This is where TreeWalker becomes invaluable. Unlike querySelectorAll, TreeWalker lets you filter nodes using arbitrary JavaScript logic:
const walker = document.createTreeWalker(container,NodeFilter.SHOW_ELEMENT,{acceptNode: (node) => {// We can run ANY JavaScript logic here!return node.tabIndex >= 0? NodeFilter.FILTER_ACCEPT: NodeFilter.FILTER_SKIP;}});// Collect all focusable elementsconst tabbables = [];while (walker.nextNode()) {tabbables.push(walker.currentNode);}
Radix UI's getTabbableCandidates function uses exactly this approach. It walks the DOM tree, checking each element's computed tabIndex property, and returns candidates in DOM order. This elegantly handles all the edge cases that made our CSS selector approach so brittle.
The Elegant Insight: Let the Browser Do Most of the Work
Now here's where Radix UI's approach gets clever. Consider what happens when you press Tab at different positions in a modal with 50 focusable elements:
Scenario 1: You're at element #23 (somewhere in the middle)
[input1] [input2] ... [input23 ← YOU] [input24] ... [input50]
The browser automatically moves focus to input24. No JavaScript needed! The browser's native tab navigation handles this perfectly.
Scenario 2: You're at the last element #50
[input1] [input2] ... [input49] [input50 ← YOU] → [outsideButton] ← ESCAPE!
The browser wants to move focus to an element outside the modal. This is the only case we need to intercept.
The insight is profound: we don't need to manage every Tab press—just the boundary cases.
Radix UI's Implementation
Radix UI's FocusScope handles this with surgical precision:
function handleKeyDown(event) {if (event.key !== 'Tab') return;if (event.altKey || event.ctrlKey || event.metaKey) return;const tabbables = getTabbableCandidates(container);const first = tabbables[0];const last = tabbables[tabbables.length - 1];const target = event.target;// Shift+Tab at first element → wrap to lastif (event.shiftKey && target === first) {event.preventDefault();last.focus();}// Tab at last element → wrap to firstif (!event.shiftKey && target === last) {event.preventDefault();first.focus();}}
This approach:
- Intercepts only at the boundaries
- Lets native browser navigation handle everything else
- Uses
preventDefault()only when necessary - Maintains the natural feel of keyboard navigation
Beyond Simple Trapping: The Focus Scope Stack
Radix UI's implementation goes further with a focus scope stack architecture. This handles a common real-world scenario: nested dialogs.
Imagine a settings modal that opens a confirmation dialog. Now you have two focus scopes:
[Page] → [Settings Modal] → [Confirmation Dialog]
The stack ensures:
- When the confirmation dialog opens, the settings modal's focus scope is paused
- Focus is trapped only within the innermost active scope
- When the confirmation closes, the settings modal's scope resumes
- Focus returns to where it was before
This is managed through custom events and a MutationObserver that tracks when scopes are added or removed from the DOM.
Focus Restoration: Closing the Loop
Good focus management doesn't end when the modal is open—it extends to when the modal closes. Where should focus go?
Radix UI stores a reference to the element that was focused before the scope activated. When the scope unmounts, focus is restored to that element. This ensures users don't lose their place in the document.
// Simplified conceptconst previouslyFocused = document.activeElement;onUnmount(() => {previouslyFocused?.focus();});
The Container as Fallback
One subtle detail: the FocusScope container itself has tabIndex={-1}. This makes it programmatically focusable (via JavaScript) without adding it to the tab order.
Why? It serves as a fallback focus target. If a focus scope becomes empty (all focusable elements removed dynamically), or if focus somehow escapes, there's always a valid target to focus within the scope.
Key Takeaways
- Don't fight the browser—leverage its native focus handling and only intercept at boundaries
- Use TreeWalker to access runtime properties that CSS selectors can't reach
- The
tabIndexproperty (not attribute) is your source of truth for focusability - Stack-based architecture handles nested focus scopes elegantly
- Focus restoration is just as important as focus trapping
Radix UI's FocusScope is a masterclass in working with the browser rather than against it. By understanding when to intercept and when to step back, it achieves robust focus management with minimal overhead.
