How to Build Tabs in React with Accessible Keyboard Navigation

Build accessible React tabs with the ARIA tabs pattern. Add roving tabindex, arrow key navigation, and connected tab panels.

6 min read

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.

App.jsxApp.jsx
<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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
{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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
<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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why use buttons for tabs instead of links?

Tabs switch visible content on the same page, which is an action, not navigation. Buttons announce the right behavior, and links would suggest a page change.

What is roving tabindex?

Roving tabindex keeps only the active tab in the Tab sequence with tabIndex 0, and sets the rest to -1. Arrow keys then move focus between tabs without tabbing through all of them.

Should arrow keys activate the tab or only move focus?

Either is valid. Automatic activation shows the panel as focus moves, which is best when panels load instantly. Manual activation requires Space or Enter and is safer when panels load slowly.

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.