A tab bar with 10 individual tabs, each focusable by Tab key, forces a keyboard user to press Tab 10 times to move through the tab list before reaching content — even though they might only want the fifth tab. This is the exact scenario roving tabindex solves. WCAG 2.2 SC 2.1.1 (Keyboard) requires keyboard operability; SC 2.4.3 (Focus Order) requires a logical, efficient focus sequence. Section 508 2018 Refresh 407.6 governs keyboard navigation. The ARIA Authoring Practices Guide specifies roving tabindex as the correct pattern for composite widgets: tabs, radio groups, menu bars, tree views, and listboxes. Implementing individual tabstops for every item in a group is both a deviation from the ARIA spec and a usability failure for keyboard users.
Medium because the absence of roving tabindex creates significant keyboard navigation overhead without blocking access to individual widget items entirely.
Implement roving tabindex: only the active/selected item in a composite widget has tabIndex={0}; all others get tabIndex={-1}. Arrow keys move active selection and shift the tabindex.
function TabGroup({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next = index;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
if (e.key === 'Home') next = 0;
if (e.key === 'End') next = tabs.length - 1;
if (next !== index) {
e.preventDefault();
setActiveIndex(next);
tabRefs.current[next]?.focus(); // Move DOM focus
}
};
return (
<>
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.id}
ref={el => { tabRefs.current[i] = el; }}
role="tab"
id={`tab-${tab.id}`}
aria-selected={i === activeIndex}
aria-controls={`panel-${tab.id}`}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(i)}
onKeyDown={(e) => handleKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={i !== activeIndex}
>
{tab.content}
</div>
))}
</>
);
}
Apply the same pattern to radio groups, menu bars, and tree views per the ARIA APG.
ID: accessibility-basics.keyboard-focus.roving-tabindex
Severity: medium
What to look for: Enumerate every relevant item. Check for composite widgets such as tab panels, radio groups, menu bars, trees, or custom lists. These should implement roving tabindex, where only one item in the group is in the tab order, and Arrow keys navigate between items. This avoids excessive tabbing.
Pass criteria: At least 1 of the following conditions is met. Composite widgets (tabs, radio groups, lists, menus) use roving tabindex. Only the current/selected item is in the tab order. Arrow keys navigate between items within the group.
Fail criteria: Composite widgets allow individual Tab stops for every item, or do not respond to Arrow keys for navigation.
Skip (N/A) when: The application has no composite widgets (tabs, radio groups, menus, custom lists).
Detail on fail: Identify which composite widgets lack roving tabindex. Example: "Tab list has every tab in the tab order. Users must Tab through all tabs to move to next section. Arrow keys do not navigate between tabs."
Remediation: Implement roving tabindex for composite widgets. Example with tabs:
const TabList = () => {
const [selectedIndex, setSelectedIndex] = useState(0);
const tabs = ['Tab 1', 'Tab 2', 'Tab 3'];
const handleKeyDown = (e) => {
let newIndex = selectedIndex;
if (e.key === 'ArrowRight') newIndex = (selectedIndex + 1) % tabs.length;
if (e.key === 'ArrowLeft') newIndex = (selectedIndex - 1 + tabs.length) % tabs.length;
if (newIndex !== selectedIndex) {
e.preventDefault();
setSelectedIndex(newIndex);
}
};
return (
<div role="tablist" onKeyDown={handleKeyDown}>
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
tabIndex={i === selectedIndex ? 0 : -1}
aria-selected={i === selectedIndex}
onClick={() => setSelectedIndex(i)}
>
{tab}
</button>
))}
</div>
);
};