August 4, 2026 · 7 min
Tabs in React the way the platform intended
Arrow keys, roving tabindex, and `aria-controls`. A tablist that works with a keyboard, not just a mouse.
- React
- TypeScript
- a11y
Try it outClick around. This is the real component.
Preview
One selected index. Only that tab sits in the tab order.
1"use client";2 3import { useId, useRef, useState } from "react";4 5type Tab = {6 id: string;7 label: string;8 content: React.ReactNode;9};10 11export function Tabs({ tabs }: { tabs: Tab[] }) {12 const baseId = useId();13 const [selected, setSelected] = useState(0);14 const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);15 16 const move = (next: number) => {17 const index = (next + tabs.length) % tabs.length;18 setSelected(index);19 tabRefs.current[index]?.focus();20 };21 22 const onKeyDown = (event: React.KeyboardEvent, index: number) => {23 if (event.key === "ArrowRight") move(index + 1);24 if (event.key === "ArrowLeft") move(index - 1);25 if (event.key === "Home") move(0);26 if (event.key === "End") move(tabs.length - 1);27 };28 29 return (30 <div>31 <div role="tablist" aria-label="Sections" className="flex gap-1">32 {tabs.map((tab, index) => {33 const selectedTab = index === selected;34 return (35 <button36 key={tab.id}37 ref={(node) => {38 tabRefs.current[index] = node;39 }}40 type="button"41 role="tab"42 id={`${baseId}-tab-${tab.id}`}43 aria-selected={selectedTab}44 aria-controls={`${baseId}-panel-${tab.id}`}45 tabIndex={selectedTab ? 0 : -1}46 onClick={() => setSelected(index)}47 onKeyDown={(event) => onKeyDown(event, index)}48 className={49 selectedTab50 ? "rounded-full bg-white/10 px-3 py-1.5 text-sm"51 : "rounded-full px-3 py-1.5 text-sm text-zinc-400"52 }53 >54 {tab.label}55 </button>56 );57 })}58 </div>59 60 {tabs.map((tab, index) => (61 <div62 key={tab.id}63 role="tabpanel"64 id={`${baseId}-panel-${tab.id}`}65 aria-labelledby={`${baseId}-tab-${tab.id}`}66 hidden={index !== selected}67 className="mt-4 text-sm leading-relaxed"68 >69 {tab.content}70 </div>71 ))}72 </div>73 );74}Most tab components are three buttons and a useState index. That works for a mouse. It fails the moment someone tabs to the control and expects the arrow keys to move between tabs, the way they do in a browser.
The WAI-ARIA tabs pattern is small: one tablist, one selected tab in the tab order, arrow keys to move, and a panel tied to the tab with aria-controls.
Usage
1import { Tabs } from "./tabs";2 3export function StackTabs() {4 return (5 <Tabs6 tabs={[7 { id: "react", label: "React", content: "Client components, state, effects." },8 { id: "server", label: "Server", content: "RSC, loaders, and caching." },9 { id: "motion", label: "Motion", content: "Transforms, not layout thrash." },10 ]}11 />12 );13}Automatic vs manual activation
This version selects as you arrow (automatic activation). If a panel is expensive to render, like a map or a heavy chart, switch to manual: arrows only move focus, Enter or Space selects. Same markup, one extra piece of state for focused vs selected.