Skip to content
All notes

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.

tabs.tsx

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 <button
36 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 selectedTab
50 ? "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 <div
62 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

example.tsx
1import { Tabs } from "./tabs";
2
3export function StackTabs() {
4 return (
5 <Tabs
6 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.