Composite widgets use roving tabindex for efficient navigation
Why it matters
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.
Severity rationale
Medium because the absence of roving tabindex creates significant keyboard navigation overhead without blocking access to individual widget items entirely.
Remediation
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.
DetectionPro
The detection method for this pattern is part of AuditBuffet Pro.
The full method (what to look for, pass and fail conditions, and how results get reported) ships inside the Accessibility Basics Audit. The pattern definition, severity rationale, and remediation remain openly licensed under CC-BY-4.0.
External references
- wcag:2.2 · 2.1.1 — Keyboard
- wcag:2.2 · 2.4.3 — Focus Order
- section-508:2018-refresh · 407.6 — Operation of Mechanical Parts
Taxons
History
- 2026-04-18·v1.0.0·Initial import from accessibility-basics·automated