Radix UI - Roving Focus
- 文章發表於
- ...
What is Roving Focus?
Roving Focus (also called "Roving TabIndex") is an accessibility pattern for managing keyboard navigation in collections of interactive elements.
Real-World Examples:
- Toolbar: Bold, Italic, Underline buttons
- Tab List: Home, Profile, Settings tabs
- Radio Group: Payment method options
- Menu Bar: File, Edit, View menus (when horizontal)
The User Experience:
Without RovingFocus:[Search] → Tab → [B1] → Tab → [B2] → Tab → [B3] → Tab → [B4] → Tab → [B5] → Tab → [Content](User must press Tab 5 times to get through toolbar!)With RovingFocus:[Search] → Tab → [Toolbar] → Tab → [Content](Inside toolbar: Arrow keys navigate between B1-B5)(User presses Tab once, toolbar acts as single tab stop!)
Why Does It Exist?
Problem 1: Tab Key Efficiency
Without roving focus, users must tab through every single item in a collection. A toolbar with 20 buttons requires 20 tab presses!
Problem 2: Semantic Grouping
Related items should be treated as a single "unit" in the page's tab order. A toolbar is conceptually ONE thing, not 20 separate things.
Problem 3: Accessibility Standards
ARIA Authoring Practices Guide (APG) recommends this pattern for:
- Toolbars
- Tab lists
- Radio groups
- Grid navigation
- Menu bars
The WCAG Principle:
"Keyboard users should be able to navigate efficiently without getting lost in collections of similar items."
Core Concepts
Focus Management vs Roving Focus
The Single Tab Stop Rule
Only ONE item in the group is part of the page's tab order at any time.
// At any given moment:<div> {/* Wrapper: tabindex may be 0 or -1 */}<button tabindex={0}>B1</button> {/* The "chosen one" */}<button tabindex={-1}>B2</button> {/* Skipped by Tab */}<button tabindex={-1}>B3</button> {/* Skipped by Tab */}</div>
Two Navigation Modes
Tab Navigation (Between Groups):
- Browser's native behavior
- Jumps between major page sections
- Respects tab order
Navigation (Within Group):
- Implemented by RovingFocus
- Moves within the collection
- Optional loop behavior
The TabIndex Strategy
Understanding TabIndex Values:
The Key Insight:
tabindex=-1 is NOT unfocusable!
- It's skipped by Tab key
- But
.focus()still works - And clicks still work
This is what makes RovingFocus possible:
// User presses Arrow Right on B1 (which has tabindex={0})onKeyDown={(event) => {if (event.key === 'ArrowRight') {// B2 has tabindex={-1}, but we can still focus it!b2Element.focus();// Update state so B2 becomes the new "chosen one"setCurrentTabStopId('b2');}}
The Roving Mechanism:
State Change Flow:1. currentTabStopId: 'b1'→ <Button id="b1" tabIndex={0} />→ <Button id="b2" tabIndex={-1} />2. User presses Arrow Right3. JavaScript: b2Element.focus()4. State Update: currentTabStopId: 'b2'5. Re-render:→ <Button id="b1" tabIndex={-1} />→ <Button id="b2" tabIndex={0} />
Architecture Overview
Component Structure:
<RovingFocusGroup> // Context Provider + Wrapper<RovingFocusGroupItem /> // Individual focusable item<RovingFocusGroupItem /><RovingFocusGroupItem /></RovingFocusGroup>
Key Dependencies
- : Tracks all items and their order for navigation
- : Shares state (currentTabStopId, orientation, loop, etc.)
- : Allows controlled or uncontrolled currentTabStopId
- : Handles RTL (right-to-left) languages
- : Base component with asChild support
State Management
The Core State:
const [currentTabStopId, setCurrentTabStopId] = useControllableState({prop: currentTabStopIdProp,defaultProp: defaultCurrentTabStopId ?? null,onChange: onCurrentTabStopIdChange,});
This single piece of state controls everything:
- Which item has
tabIndex={0} - Where focus goes when entering the group
- Which item to return to when tabbing back
Context Value Shared with Items:
type RovingContextValue = {currentTabStopId: string | null;orientation?: 'horizontal' | 'vertical';dir?: 'ltr' | 'rtl';loop?: boolean;onItemFocus(tabStopId: string): void;onItemShiftTab(): void;onFocusableItemAdd(): void;onFocusableItemRemove(): void;};
How items use context:
// In RovingFocusGroupItemconst context = useRovingFocusContext(ITEM_NAME, __scopeRovingFocusGroup);const isCurrentTabStop = context.currentTabStopId === id;return (<spantabIndex={isCurrentTabStop ? 0 : -1} // ← Reactive to contextonFocus={() => context.onItemFocus(id)}/>);
When currentTabStopId changes:
- Context value updates
- All items that consume context re-render
- Each item recalculates
isCurrentTabStop - Only 2 items change their tabIndex in DOM
Keyboard Navigation Logic
The Focus Intent Map
const MAP_KEY_TO_FOCUS_INTENT = {ArrowLeft: 'prev',ArrowUp: 'prev',ArrowRight: 'next',ArrowDown: 'next',PageUp: 'first',Home: 'first',PageDown: 'last',End: 'last',};
Orientation Filtering
/*** LTR: [B1] [B2] [B3] →* RTL: ← [B3] [B2] [B1]** ArrowLeft in LTR = prev (B1 ← B2)* ArrowLeft in RTL = next (B2 → B1 visually, but B2 → B3 logically)*/function getDirectionAwareKey(key, dir) {if (dir !== 'rtl') return key;// In RTL, left means "next" and right means "prev"return key === 'ArrowLeft' ? 'ArrowRight': key === 'ArrowRight' ? 'ArrowLeft': key;}function getFocusIntent(event, orientation, dir) {const key = getDirectionAwareKey(event.key, dir);// Horizontal group ignores vertical arrowsif (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) {return undefined;}// Vertical group ignores horizontal arrowsif (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) {return undefined;}return MAP_KEY_TO_FOCUS_INTENT[key];}
Navigation with Loop
if (focusIntent === 'prev' || focusIntent === 'next') {let candidateNodes = items.map(item => item.ref.current);if (focusIntent === 'prev') {candidateNodes.reverse();}const currentIndex = candidateNodes.indexOf(event.currentTarget);candidateNodes = context.loop? wrapArray(candidateNodes, currentIndex + 1) // Loop enabled: candidateNodes.slice(currentIndex + 1); // Stop at ends}function wrapArray(array, startIndex) {// [A, B, C, D] with startIndex=2 → [C, D, A, B]return array.map((_, index) => array[(startIndex + index) % array.length]);}
The focusFirst Helper
function focusFirst(candidates, preventScroll = false) {const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;for (const candidate of candidates) {// Already where we want to beif (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;candidate.focus({ preventScroll });// Check if focus actually moved (some elements might not be focusable)if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;}}
Why iterate? Some candidates might be:
- Disabled
- Hidden
- Not focusable for browser-specific reasons
This ensures we find the first actually focusable element.
