Choosing @reach/tabs Over Hand-Rolled ARIA Tabs in a React Project
When building a tabbed interface, using @reach/tabs gives you built-in accessibility while keeping bundle size low and avoiding custom keyboard logic.
12 Dec 2025, 02:00 UTC

Adding tabs to a React interface looks trivial until you test it with a keyboard. The WAI-ARIA authoring practices define a precise contract: a container with role="tablist", children with role="tab" that expose aria-selected and aria-controls, and panels with role="tabpanel". Arrow keys must move focus between tabs, and only the active tab should be reachable by the Tab key. Hand-rolling this contract means synchronizing IDs, selected state, and focus behavior in every component that uses tabs. A single missing attribute or an off-by-one keyboard handler turns a simple widget into an accessibility bug.
The useful takeaway: @reach/tabs is a pragmatic default for standard tabbed interfaces because it encodes that contract into a small React primitive. You supply labels and panel content; the library manages the ARIA roles, the aria-selected state, and arrow-key navigation. That trade—giving up some styling freedom and bundle-size budget in exchange for correctness—usually pays off unless your tabs need deeply custom behavior.
The hidden cost of hand-rolled tabs
ARIA (Accessible Rich Internet Applications) does not make an element accessible by itself; it describes state to assistive technology. A tab widget must tell a screen reader which tab is selected, which panel it controls, and how to move between tabs. In a hand-rolled version, you typically track the selected index in React state, generate unique IDs, wire aria-controls to the matching panel, and implement an onKeyDown handler for ArrowLeft, ArrowRight, Home, and End. You also manage tabIndex so that only the active tab is in the document’s tab order.
Each of those details is a potential failure point. If focus moves but selection does not, keyboard users see a panel that does not match the focused tab. If aria-controls points to the wrong ID, screen reader users hear a label with no associated content. These bugs are easy to introduce and hard to notice without dedicated assistive-technology testing.
What @reach/tabs actually does
@reach/tabs exports four components: Tabs, TabList, Tab, TabPanels, and TabPanel. The Tabs wrapper holds the selected-tab state. TabList renders the tab strip, each Tab renders a label, and TabPanels / TabPanel render the content regions. Under the hood, the library sets the correct ARIA roles and attributes and handles arrow-key navigation automatically.
Focus and selected-tab state are internal to the components. You do not write custom keyboard handlers or manage ID relationships. Because it is a primitive rather than a design system, it does not impose visual styles or animation behavior; it gives you semantic structure and keyboard behavior and leaves presentation to your CSS.
A minimal integration and how to verify it
Install the package from your project root. You need write access to the project directory and an existing React application.
npm install @reach/tabs
After installation, confirm the dependency resolved by checking your package.json or running npm ls @reach/tabs in the same terminal. React is a peer dependency, so your application must already provide a compatible React version.
A basic tabbed interface looks like this:
import { Tabs, TabList, Tab, TabPanels, TabPanel } from "@reach/tabs";
function SettingsTabs() {
return (
<Tabs>
<TabList>
<Tab>Profile</Tab>
<Tab>Notifications</Tab>
<Tab>Billing</Tab>
</TabList>
<TabPanels>
<TabPanel>Profile form here.</TabPanel>
<TabPanel>Notification preferences here.</TabPanel>
<TabPanel>Billing history here.</TabPanel>
</TabPanels>
</Tabs>
);
}
To verify the rendered markup, open the page in a browser, right-click the tab strip, and choose Inspect. You should see an element with role="tablist" containing elements with role="tab". The selected tab should have aria-selected="true" and a tabindex of 0; unselected tabs should have aria-selected="false" and a tabindex of -1. Each tab should reference its panel through aria-controls, and the visible panel should carry role="tabpanel".
Next, test keyboard behavior. Press Tab until focus lands on the active tab. Press the right and left arrow keys and confirm focus moves between tabs and the matching panel appears. Run an accessibility audit tool such as axe or Lighthouse on the page and resolve any violations before shipping.
Trade-offs and limitations
Adopting @reach/tabs is a decision about where to spend engineering effort. The library improves accessibility correctness, but it adds a runtime dependency and increases bundle size. If your project already includes a design system with its own accessible tabs, the marginal benefit may be small.
Styling freedom is another constraint. Because the component manages its own DOM structure and state, heavily customized tabs—those with animated indicators, overflow scroll behavior, or non-standard layouts—may require wrapper CSS or layout adjustments rather than overriding internal styles. Reach Tabs is a primitive, not a design system; animations, popups, or interactions that fall outside the tab pattern need other components.
Server rendering and React StrictMode deserve explicit testing. Depending on the installed release, StrictMode or server-side rendering can change focus or hydration behavior. Before production use, check the package registry and repository for the current maintenance status and API stability, then render your tabs in your actual SSR or StrictMode environment to confirm focus management works as expected.
When to adopt it
Use @reach/tabs when you need a standard tabbed interface and want correct keyboard and screen-reader behavior without maintaining that logic yourself. Start with the primitive, verify the ARIA output and arrow-key navigation in your app, and run an accessibility audit. Reserve a fully custom component for interactions the primitive does not support—and only after you have measured the gap.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.