How to Create a Reusable Tabs Component in React: Best Practices and Implementation Guide
Build a reusable tabs component in React by combining the useId hook for stable ARIA identifiers, context API for state sharing, and proper keyboard navigation handlers, supporting both controlled and uncontrolled modes as implemented in the facebook/react repository.
Creating a reusable tabs component in React requires balancing accessibility, state management, and composition patterns. The React source code demonstrates these principles through components like TabbedWindow.tsx in the compiler playground, which leverages concurrent features and stable ID generation. This guide shows you how to implement a tabs component that follows the same architectural patterns used in the facebook/react repository.
Architectural Patterns for React Tabs
Controlled vs Uncontrolled State Ownership
A robust tabs component must support both controlled and uncontrolled state patterns. In controlled mode, the parent component passes the activeTab value and an onTabChange callback, allowing external state management. In uncontrolled mode, the component manages its own state internally using useState. This flexibility lets library users either drive the selection logic manually or let the component handle it automatically, matching the pattern used in compiler/apps/playground/components/TabbedWindow.tsx.
Stable ID Generation with useId
Accessibility requires unique identifiers to link tabs with their corresponding panels. Use React's built-in useId hook to generate stable IDs across server and client renders. According to the implementation in packages/react/src/ReactHooks.js, this hook guarantees unique identifiers without leaking IDs across renders or hydration mismatches. This is critical for ARIA relationships where aria-controls must reference a specific panel ID.
ARIA Accessibility Requirements
Meeting WAI-ARIA Authoring Practices requires specific attributes:
- Container:
role="tablist"witharia-orientation="horizontal" - Tab buttons:
role="tab",aria-selected(boolean),aria-controls(panel ID), andid(unique identifier) - Panels:
role="tabpanel",aria-labelledby(tab ID), andid(matchingaria-controls)
These attributes enable screen readers to announce tab relationships and selection states correctly.
Keyboard Navigation Implementation
Implement keyboard handlers to support ArrowLeft, ArrowRight, Home, and End keys. When focus is on a tab, the right arrow moves focus and selection to the next tab, while the left arrow moves to the previous. Home and End keys jump to the first and last tabs respectively. This implementation mirrors the accessibility patterns found in compiler/apps/playground/components/AccordionWindow.tsx.
Building the Tabs Component
The component architecture uses React Context to share state between the container and sub-components. Create three main exports: Tabs, TabList, and TabPanel. This composition pattern allows consumers to arrange and style elements arbitrarily while maintaining internal logic.
// src/components/Tabs.tsx
import React, {useId, useCallback, useEffect, useState, startTransition} from 'react';
import clsx from 'clsx';
type TabsProps = {
/** Controlled selected key – if omitted the component is uncontrolled */
selectedKey?: string;
/** Called when user selects a different tab */
onSelect?: (key: string) => void;
/** Children – usually <TabList> and <TabPanel> */
children: React.ReactNode;
};
export function Tabs({selectedKey, onSelect, children}: TabsProps) {
// Uncontrolled fallback state
const [internalKey, setInternalKey] = useState<string | null>(null);
const isControlled = selectedKey !== undefined && onSelect !== undefined;
// Resolve the current key
const activeKey = isControlled ? selectedKey! : internalKey;
const requestSelect = useCallback(
(key: string) => {
// Use startTransition for smoother UI when panel is heavy
startTransition(() => {
if (isControlled) {
onSelect?.(key);
} else {
setInternalKey(key);
}
});
},
[isControlled, onSelect],
);
// Provide context to descendants
const ctx = {activeKey, requestSelect, isControlled};
return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>;
}
/* ------------------------------------------------------------------ */
/* Sub‑components – they read the context created above */
/* ------------------------------------------------------------------ */
type TabListProps = {children: React.ReactNode};
export function TabList({children}: TabListProps) {
const id = useId(); // unique base id for ARIA
return (
<div role="tablist" aria-orientation="horizontal" className="flex">
{React.Children.map(children, (child, idx) =>
React.cloneElement(child as React.ReactElement<any>, {
tabId: `${id}-tab-${idx}`,
panelId: `${id}-panel-${idx}`,
}),
)}
</div>
);
}
type TabProps = {
/** Unique key for this tab – also used as the value passed to onSelect */
tabKey: string;
/** IDs injected by TabList (internal) */
tabId?: string;
panelId?: string;
children: React.ReactNode;
};
export function Tab({tabKey, tabId, panelId, children}: TabProps) {
const {activeKey, requestSelect} = React.useContext(TabsContext);
const selected = activeKey === tabKey;
// Keyboard navigation (←/→/Home/End)
const onKeyDown = (e: React.KeyboardEvent) => {
const tabs = Array.from(
(e.currentTarget.parentElement?.children ?? []) as HTMLButtonElement[],
);
const curIdx = tabs.findIndex(t => t.id === tabId);
let nextIdx = curIdx;
switch (e.key) {
case 'ArrowRight':
nextIdx = (curIdx + 1) % tabs.length;
break;
case 'ArrowLeft':
nextIdx = (curIdx - 1 + tabs.length) % tabs.length;
break;
case 'Home':
nextIdx = 0;
break;
case 'End':
nextIdx = tabs.length - 1;
break;
default:
return;
}
tabs[nextIdx].focus();
requestSelect(tabs[nextIdx].dataset.key!);
};
return (
<button
id={tabId}
role="tab"
type="button"
aria-selected={selected}
aria-controls={panelId}
data-key={tabKey}
tabIndex={selected ? 0 : -1}
onClick={() => requestSelect(tabKey)}
onKeyDown={onKeyDown}
className={clsx(
'px-4 py-2 border-b',
selected ? 'border-primary text-primary' : 'border-transparent',
)}
>
{children}
</button>
);
}
type TabPanelProps = {
/** Must match the tabKey of the associated Tab */
tabKey: string;
/** IDs injected by TabList */
tabId?: string;
panelId?: string;
children: React.ReactNode;
};
export function TabPanel({tabKey, tabId, panelId, children}: TabPanelProps) {
const {activeKey} = React.useContext(TabsContext);
const hidden = activeKey !== tabKey;
return (
<div
id={panelId}
role="tabpanel"
aria-labelledby={tabId}
hidden={hidden}
className="p-4"
>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Context – internal, not exported publicly */
/* ------------------------------------------------------------------ */
type TabsContextType = {
activeKey: string | null;
requestSelect: (key: string) => void;
isControlled: boolean;
};
const TabsContext = React.createContext<TabsContextType>({
activeKey: null,
requestSelect: () => {},
isControlled: false,
});
Usage Examples
Controlled Implementation
In controlled mode, you manage the active state in the parent component, allowing integration with routing or external state management.
// src/App.tsx
import React, {useState} from 'react';
import {Tabs, TabList, Tab, TabPanel} from './components/Tabs';
export default function App() {
const [selected, setSelected] = useState('profile');
return (
<Tabs selectedKey={selected} onSelect={setSelected}>
<TabList>
<Tab tabKey="profile">Profile</Tab>
<Tab tabKey="settings">Settings</Tab>
<Tab tabKey="stats">Stats</Tab>
</TabList>
<TabPanel tabKey="profile">
<h2>Profile</h2>
<p>Profile content here</p>
</TabPanel>
<TabPanel tabKey="settings">
<h2>Settings</h2>
<p>Settings content here</p>
</TabPanel>
<TabPanel tabKey="stats">
<h2>Stats</h2>
<p>Statistics content here</p>
</TabPanel>
</Tabs>
);
}
Uncontrolled Implementation
For simpler use cases, omit the selectedKey and onSelect props to let the component manage its own state.
<Tabs>
<TabList>
<Tab tabKey="a">First Tab</Tab>
<Tab tabKey="b">Second Tab</Tab>
</TabList>
<TabPanel tabKey="a">First panel content</TabPanel>
<TabPanel tabKey="b">Second panel content</TabPanel>
</Tabs>
Performance Optimizations with Concurrent Features
When switching between tabs with heavy content, wrap state updates in startTransition to prevent UI jank. As implemented in compiler/apps/playground/components/TabbedWindow.tsx, this allows React to prioritize urgent updates while deferring the rendering of new tab content. The requestSelect function in the example above demonstrates this pattern by wrapping the state change in startTransition, ensuring the tab switch feels immediate even if the panel content is complex.
Summary
- Use
useIdfrompackages/react/src/ReactHooks.jsto generate stable, SSR-safe identifiers for ARIA attributes. - Support both controlled and uncontrolled modes to provide flexibility for different use cases.
- Implement keyboard navigation for ArrowLeft, ArrowRight, Home, and End keys to meet WAI-ARIA standards.
- Wrap state updates in
startTransitionwhen switching tabs to maintain responsive UI, following the pattern inTabbedWindow.tsx. - Compose with sub-components (
TabList,Tab,TabPanel) to allow flexible styling and layout while maintaining internal logic through Context.
Frequently Asked Questions
Should I use controlled or uncontrolled tabs for my application?
Use controlled tabs when you need to synchronize the tab state with URL parameters, external state management (Redux, Zustand), or when you need to programmatically change tabs from parent components. Use uncontrolled tabs for self-contained UI where the tab state doesn't need to persist or interact with external systems, reducing boilerplate in simple use cases.
How do I ensure my tabs component is fully accessible?
Implement role="tablist" on the container, role="tab" on buttons with aria-selected and aria-controls, and role="tabpanel" on content areas with aria-labelledby. Use the useId hook to generate unique IDs for these relationships, and handle keyboard events for ArrowLeft, ArrowRight, Home, and End keys to enable full keyboard navigation as required by WAI-ARIA Authoring Practices.
Why does the implementation use startTransition when switching tabs?
The startTransition API, used in compiler/apps/playground/components/TabbedWindow.tsx, marks the tab content update as non-urgent. This prevents heavy panel renders from blocking urgent updates like user input or animations, keeping the interface responsive when switching between content-heavy tabs in concurrent React applications.
How does useId prevent hydration mismatches in server-side rendering?
According to packages/react/src/ReactHooks.js and verified in packages/react-dom/src/__tests__/ReactDOMUseId-test.js, useId generates identifiers that are stable across server and client renders by using an internal counter that increments consistently regardless of suspense boundaries or component order, ensuring the IDs match during hydration and preventing accessibility attribute mismatches.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →