TIL

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
  • Implemented by RovingFocus
  • Moves within the collection
  • Optional loop behavior

The TabIndex Strategy

Understanding TabIndex Values:

ValueTab Key BehaviorJS .focus()Mouse Click
0Included in tab orderFocusableFocusable
-1Skipped by tab orderFocusableFocusable
> 0Custom order (anti-pattern)FocusableFocusable

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 Right
3. 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 RovingFocusGroupItem
const context = useRovingFocusContext(ITEM_NAME, __scopeRovingFocusGroup);
const isCurrentTabStop = context.currentTabStopId === id;
return (
<span
tabIndex={isCurrentTabStop ? 0 : -1} // ← Reactive to context
onFocus={() => 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 arrows
if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) {
return undefined;
}
// Vertical group ignores horizontal arrows
if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) {
return undefined;
}
return MAP_KEY_TO_FOCUS_INTENT[key];
}
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 be
if (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.

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee