Keyboard navigation for tabs follows the WAI-ARIA Tabs pattern: Arrow keys switch between tabs, Tab moves focus into the active panel.
Think of tabs like folders in a filing cabinet — the tab labels are the folder tabs at the top, and the content panels are the folders themselves. A keyboard user needs to flip between tabs (Arrow Left/Right) and then reach into the folder content (Tab key).
1import { useState, useRef, useCallback } from "react";23interface Tab {4 id: string;5 label: string;6 content: React.ReactNode;7}89function AccessibleTabs({ tabs }: { tabs: Tab[] }) {10 const [activeIndex, setActiveIndex] = useState(0);11 const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);1213 const handleKeyDown = useCallback((e: React.KeyboardEvent) => {14 const tabCount = tabs.length;15 let newIndex = activeIndex;1617 switch (e.key) {18 case "ArrowRight":19 newIndex = (activeIndex + 1) % tabCount;20 break;21 case "ArrowLeft":22 newIndex = (activeIndex - 1 + tabCount) % tabCount;23 break;24 case "Home":25 newIndex = 0;26 break;27 case "End":28 newIndex = tabCount - 1;29 break;30 default:31 return;32 }3334 e.preventDefault();35 setActiveIndex(newIndex);36 tabRefs.current[newIndex]?.focus();37 }, [activeIndex, tabs.length]);3839 return (40 <div>41 <div role="tablist" aria-label="Information">42 {tabs.map((tab, i) => (43 <button44 key={tab.id}45 ref={el => tabRefs.current[i] = el}46 role="tab"47 id={`tab-${tab.id}`}48 aria-selected={i === activeIndex}49 aria-controls={`panel-${tab.id}`}50 tabIndex={i === activeIndex ? 0 : -1}51 onClick={() => setActiveIndex(i)}52 onKeyDown={handleKeyDown}53 >54 {tab.label}55 </button>56 ))}57 </div>5859 {tabs.map((tab, i) => (60 <div61 key={tab.id}62 role="tabpanel"63 id={`panel-${tab.id}`}64 aria-labelledby={`tab-${tab.id}`}65 hidden={i !== activeIndex}66 >67 {tab.content}68 </div>69 ))}70 </div>71 );72}
How it works step-by-step:
The roving tabindex pattern: Only the active tab has tabIndex={0}. All other tabs have tabIndex={-1}. This means pressing Tab moves focus to the active tab, and Arrow keys move between tabs — keeping the tab group within a single Tab stop.
Performance considerations:
useCallback for the handleKeyDown handler to avoid re-creating it on every render.hidden or {i === activeIndex && ...}).Common pitfalls:
aria-selected — screen readers cannot distinguish the active tab.aria-controls to aria-labelledby — the tab-panel relationship is invisible to assistive technology.tabIndex={0} on all tabs — this creates multiple Tab stops in the tab group, breaking the expected keyboard flow.hidden — panels not visible to sighted users are still accessible to screen readers if not hidden.Integration with React Router:
1import { useNavigate, useLocation } from "react-router-dom";23function TabNavigation() {4 const navigate = useNavigate();5 const location = useLocation();6 const tabs = [7 { id: "overview", label: "Overview", path: "/docs/overview" },8 { id: "api", label: "API Reference", path: "/docs/api" },9 { id: "examples", label: "Examples", path: "/docs/examples" }10 ];11 const activeIndex = tabs.findIndex(t => location.pathname === t.path);1213 // Arrow keys navigate between tabs AND update the URL14 const handleKeyDown = (e: React.KeyboardEvent) => {15 if (e.key === "ArrowRight") {16 e.preventDefault();17 const next = (activeIndex + 1) % tabs.length;18 navigate(tabs[next].path);19 }20 };2122 return (23 <div role="tablist">24 {tabs.map((tab, i) => (25 <button26 key={tab.id}27 role="tab"28 aria-selected={i === activeIndex}29 tabIndex={i === activeIndex ? 0 : -1}30 onClick={() => navigate(tab.path)}31 onKeyDown={handleKeyDown}32 >33 {tab.label}34 </button>35 ))}36 </div>37 );38}
Key summary: ArrowRight/Left — between tabs, Home/End — to first/last, Tab — into panel.