Accessible React tabs let users switch panels with a mouse and with keyboard navigation, while screen readers announce the active tab. The implementation is a tablist of buttons, a small piece of state, and connected panels that each show one block of content.
The ARIA tabs structure
A tab list is a set of buttons marked with the tab role, wrapped in an element with the tablist role. Two tabs look like this.
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-profile" aria-selected="true"
aria-controls="panel-profile" tabIndex={0}>Profile</button>
<button role="tab" id="tab-billing" aria-selected="false"
aria-controls="panel-billing" tabIndex={-1}>Billing</button>
</div>The aria-selected state names the active tab, aria-controls points each tab at its panel, and the tabindex difference is the roving tabindex that keeps only one tab in the keyboard Tab sequence.
Drive the active tab with state
Instead of hardcoding two tabs, store the tab list and the active id in state. The active tab is the only one with tabIndex 0, and its aria-selected is true.
const tabs = [
{ id: "profile", label: "Profile" },
{ id: "billing", label: "Billing" },
{ id: "settings", label: "Settings" },
];
const [active, setActive] = useState("profile");The tabs array is data, and active is the single source of truth for which panel is visible. Nothing else stores a copy of the selection.
Render the tab buttons from the data
Mapping the array produces the same buttons as the hand written version, now driven by state.
{tabs.map((tab) => (
<button
key={tab.id}
ref={(node) => (tabRefs.current[tab.id] = node)}
role="tab"
aria-selected={tab.id === active}
aria-controls={`panel-${tab.id}`}
tabIndex={tab.id === active ? 0 : -1}
onClick={() => setActive(tab.id)}
onKeyDown={onKeyDown}
>
{tab.label}
</button>
))}Each button reports whether it is selected, owns one panel, and participates in the tab order only when active. The callback ref records each button in a map so the arrow keys can focus it later.
Move focus with the arrow keys
The keyboard handler finds the current tab, steps left or right with wrapping, and moves focus to the new active tab.
const tabRefs = useRef({});
function onKeyDown(event) {
const index = tabs.findIndex((tab) => tab.id === active);
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
if (event.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
if (next !== index) {
setActive(tabs[next].id);
tabRefs.current[tabs[next].id]?.focus();
}
}ArrowLeft and ArrowRight move focus and activate the matching tab, which is the automatic activation style the ARIA pattern recommends when panels appear instantly. Pressing the keys from the last tab wraps to the first.
Connect the panels
Each panel carries the tabpanel role, points back to its tab with aria-labelledby, and hides when it is not active.
<div
role="tabpanel"
id="panel-profile"
aria-labelledby="tab-profile"
hidden={active !== "profile"}
>
Profile settings go here.
</div>The hidden attribute removes the inactive panel from the accessibility tree and the page, so only one panel is announced and displayed at a time. Each tab in the array needs its own matching panel.
Activation follows focus
The code above activates each tab as focus moves to it, which the ARIA pattern calls automatic activation. It works well when panels render instantly because the user sees each panel without an extra keypress.
Manual activation is the alternative: arrow keys move focus only, and Space or Enter activates the focused tab. Choose manual activation when a panel loads slowly or fetches data, so moving across tabs does not trigger requests for panels the user never opens.
The roles and states here are the same ARIA mechanics explained in ARIA in React, and the shared state pattern behind them is the compound components technique.
Rune AI
Key Insights
- Wrap the buttons in role tablist and each button in role tab.
- Set aria-selected on the active tab and aria-controls on each.
- Give the active tab tabIndex 0 and the rest -1.
- Move focus with ArrowLeft and ArrowRight.
- Connect each panel with role tabpanel and aria-labelledby.
Frequently Asked Questions
Why use buttons for tabs instead of links?
What is roving tabindex?
Should arrow keys activate the tab or only move focus?
Conclusion
Accessible React tabs combine three ARIA roles with a small state machine. Mark the tablist, tabs, and panels, give only the active tab tabIndex 0, and move focus with the arrow keys while activating the matching panel.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.