Building an Accessible React Tabs Component in React (WAI‑ARIA, Keyboard, TypeScript)
Build an accessible React Tabs component with WAI-ARIA roles, keyboard support, roving tabindex, and TypeScript—complete code and best practices.
Image used for representation purposes only.
Why accessible tabs matter
Tabs are a common way to organize related content without overwhelming users. But many tab implementations overlook accessibility: missing roles, broken keyboard support, focus traps, or unlabeled regions. In this article, we’ll implement an accessible React Tabs component that follows the WAI‑ARIA Authoring Practices: correct roles and relationships, roving tabindex, and robust keyboard navigation—using modern React and TypeScript.
Requirements checklist
Before we write code, here’s the bar we need to meet:
- Markup semantics
- Tablist container: role=“tablist” (with optional aria-orientation)
- Each tab: role=“tab”, aria-selected, aria-controls, id
- Each panel: role=“tabpanel”, aria-labelledby, id, hidden when inactive
- Keyboard interaction
- Horizontal tablists: Left/Right move focus; Home jumps to first; End to last
- Vertical tablists: Up/Down move focus (optional); Home/End work
- Space/Enter activate in manual activation mode; in automatic mode, arrowing also activates
- Roving tabindex: only the focused tab is tabbable (tabIndex=0); others get tabIndex=-1
- Focus management
- Focus stays on the tab; panel content is available next in the reading order
- State and props
- Controlled and uncontrolled selection
- Optional activationMode: ‘automatic’ | ‘manual’
- Optional orientation: ‘horizontal’ | ‘vertical’
- Disabled tabs are focusable-by-roving but skipped in activation and focus movement
- Labelling
- Tablist should be labeled via aria-label or aria-labelledby
Component API design
We’ll create four primitives:
- Tabs: state container (controlled/uncontrolled), context provider
- TabList: renders the tablist and handles orientation and labeling
- Tab: an individual tab (button-like) with proper roles and keyboard logic
- TabPanel: the associated panel, shown/hidden based on selection
Props overview:
- <Tabs value? defaultValue? onValueChange? orientation=“horizontal” activationMode=“automatic”> …
- <TabList aria-label? aria-labelledby?> …
- <Tab value disabled?>Label
…content…
Implementation (TypeScript + React 18)
Below is a concise, production-ready baseline. You can split files as you prefer; shown here together for clarity.
// Tabs.tsx
import React from 'react';
type ActivationMode = 'automatic' | 'manual';
type TabsContextValue = {
selectedValue: string | null;
setSelectedValue: (v: string) => void;
activationMode: ActivationMode;
orientation: 'horizontal' | 'vertical';
registerTab: (t: TabRegistration) => () => void;
getEnabledTabs: () => TabRegistration[];
};
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error('Tabs components must be used within <Tabs>.');
return ctx;
}
type TabRegistration = {
value: string;
id: string;
ref: React.RefObject<HTMLButtonElement>;
disabled?: boolean;
};
type TabsProps = React.PropsWithChildren<{
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
activationMode?: ActivationMode;
orientation?: 'horizontal' | 'vertical';
}>;
export function Tabs({
value,
defaultValue,
onValueChange,
activationMode = 'automatic',
orientation = 'horizontal',
children,
}: TabsProps) {
const isControlled = value !== undefined;
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | null>(
defaultValue ?? null
);
const selectedValue = isControlled ? (value as string | null) : uncontrolledValue;
const setSelectedValue = React.useCallback(
(v: string) => {
if (!isControlled) setUncontrolledValue(v);
onValueChange?.(v);
},
[isControlled, onValueChange]
);
const tabsRef = React.useRef<TabRegistration[]>([]);
const registerTab = React.useCallback((t: TabRegistration) => {
tabsRef.current = [...tabsRef.current.filter(x => x.value !== t.value), t];
return () => {
tabsRef.current = tabsRef.current.filter(x => x.value !== t.value);
};
}, []);
const getEnabledTabs = React.useCallback(() => {
return tabsRef.current.filter(t => !t.disabled);
}, []);
const ctx: TabsContextValue = React.useMemo(
() => ({ selectedValue, setSelectedValue, activationMode, orientation, registerTab, getEnabledTabs }),
[selectedValue, setSelectedValue, activationMode, orientation, registerTab, getEnabledTabs]
);
return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>;
}
// TabList.tsx
export function TabList({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { orientation } = useTabsContext();
return (
<div role="tablist" aria-orientation={orientation} {...props}>
{children}
</div>
);
}
// utils for IDs shared by Tab and TabPanel
function useStableId(prefix: string) {
const reactId = (React as any).useId ? (React as any).useId() : Math.random().toString(36).slice(2);
return `${prefix}-${reactId}`;
}
function tabId(base: string, value: string) {
return `${base}-tab-${value}`;
}
function panelId(base: string, value: string) {
return `${base}-panel-${value}`;
}
// Tab.tsx
type TabProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
value: string;
disabled?: boolean;
};
export function Tab({ value, disabled, onKeyDown, onClick, ...rest }: TabProps) {
const { selectedValue, setSelectedValue, registerTab, getEnabledTabs, activationMode, orientation } = useTabsContext();
const base = useStableId('tabs');
const ref = React.useRef<HTMLButtonElement>(null);
const id = tabId(base, value);
const controls = panelId(base, value);
const selected = selectedValue === value;
React.useEffect(() => registerTab({ value, id, ref, disabled }), [value, id, disabled, registerTab]);
function focusTab(target: TabRegistration | undefined) {
target?.ref.current?.focus();
}
function moveFocus(delta: number) {
const tabs = getEnabledTabs();
if (!tabs.length) return;
const currentIndex = Math.max(0, tabs.findIndex(t => t.value === value));
const nextIndex = (currentIndex + delta + tabs.length) % tabs.length;
const next = tabs[nextIndex];
focusTab(next);
if (activationMode === 'automatic' && next) setSelectedValue(next.value);
}
function onKeyDownInternal(e: React.KeyboardEvent<HTMLButtonElement>) {
const horizontal = orientation === 'horizontal';
switch (e.key) {
case 'ArrowRight':
if (horizontal) { e.preventDefault(); moveFocus(1); }
break;
case 'ArrowLeft':
if (horizontal) { e.preventDefault(); moveFocus(-1); }
break;
case 'ArrowDown':
if (!horizontal) { e.preventDefault(); moveFocus(1); }
break;
case 'ArrowUp':
if (!horizontal) { e.preventDefault(); moveFocus(-1); }
break;
case 'Home':
e.preventDefault();
focusFirst();
break;
case 'End':
e.preventDefault();
focusLast();
break;
case 'Enter':
case ' ': // Space
if (activationMode === 'manual') {
e.preventDefault();
setSelectedValue(value);
}
break;
}
onKeyDown?.(e);
}
function focusFirst() {
const tabs = getEnabledTabs();
if (tabs[0]) {
tabs[0].ref.current?.focus();
if (activationMode === 'automatic') setSelectedValue(tabs[0].value);
}
}
function focusLast() {
const tabs = getEnabledTabs();
const last = tabs[tabs.length - 1];
if (last) {
last.ref.current?.focus();
if (activationMode === 'automatic') setSelectedValue(last.value);
}
}
return (
<button
ref={ref}
id={id}
role="tab"
type="button"
aria-selected={selected}
aria-controls={controls}
// Roving tabindex: only the selected (or currently focused) tab should be tabbable
tabIndex={selected ? 0 : -1}
aria-disabled={disabled || undefined}
disabled={disabled}
onKeyDown={onKeyDownInternal}
onClick={(e) => {
if (!disabled) setSelectedValue(value);
onClick?.(e);
}}
{...rest}
/>
);
}
// TabPanel.tsx
type TabPanelProps = React.HTMLAttributes<HTMLDivElement> & {
value: string;
};
export function TabPanel({ value, children, ...rest }: TabPanelProps) {
const { selectedValue } = useTabsContext();
const base = useStableId('tabs');
const labelledBy = tabId(base, value);
const id = panelId(base, value);
const hidden = selectedValue !== value;
return (
<div
role="tabpanel"
id={id}
aria-labelledby={labelledBy}
hidden={hidden}
{...rest}
>
{!hidden && children}
</div>
);
}
Minimal styles
Use CSS that respects focus visibility and communicates state without relying solely on color.
/* tabs.css */
.tablist {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #e5e7eb;
}
.tab {
appearance: none;
background: none;
border: none;
padding: 0.5rem 0.75rem;
border-bottom: 2px solid transparent;
color: #374151;
cursor: pointer;
}
.tab[aria-selected="true"] {
border-bottom-color: #2563eb;
color: #1f2937;
font-weight: 600;
}
.tab[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.tab:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
border-radius: 4px;
}
.tabpanel {
padding: 0.75rem 0;
}
Usage example
Here’s how you might use the primitives in an app. Note the labeling via aria-label on the TabList.
import { Tabs, TabList, Tab, TabPanel } from './Tabs';
import './tabs.css';
export default function Example() {
return (
<Tabs defaultValue="account" activationMode="manual" orientation="horizontal">
<TabList className="tablist" aria-label="Profile sections">
<Tab className="tab" value="account">Account</Tab>
<Tab className="tab" value="security">Security</Tab>
<Tab className="tab" value="billing">Billing</Tab>
</TabList>
<TabPanel className="tabpanel" value="account">
<h3>Account</h3>
<p>Update your personal information and preferences.</p>
</TabPanel>
<TabPanel className="tabpanel" value="security">
<h3>Security</h3>
<p>Change your password and manage multi‑factor authentication.</p>
</TabPanel>
<TabPanel className="tabpanel" value="billing">
<h3>Billing</h3>
<p>View invoices and update payment methods.</p>
</TabPanel>
</Tabs>
);
}
Tip: Use controlled mode when you need to sync selection with the URL or application state.
function ControlledTabs() {
const [value, setValue] = React.useState('a11y');
return (
<Tabs value={value} onValueChange={setValue} activationMode="automatic">
<TabList aria-label="Docs">
<Tab className="tab" value="a11y">Accessibility</Tab>
<Tab className="tab" value="api">API</Tab>
<Tab className="tab" value="faq">FAQ</Tab>
</TabList>
<TabPanel className="tabpanel" value="a11y">…</TabPanel>
<TabPanel className="tabpanel" value="api">…</TabPanel>
<TabPanel className="tabpanel" value="faq">…</TabPanel>
</Tabs>
);
}
Keyboard behavior, summarized
- Tab key: moves focus into the selected tab (roving focus makes only one tab tabbable)
- Arrow keys: move focus between tabs (Left/Right for horizontal; Up/Down for vertical)
- Home/End: jump to first/last tab
- Space/Enter: activate the focused tab in manual mode; in automatic mode, arrowing also activates
- Disabled tabs: are skipped in focus movement and cannot be activated
Labels and announcements
- Provide a programmatic name for the tablist with aria-label or aria-labelledby
- Each tab must own an id referenced by its panel’s aria-labelledby
- Each tab must point to its panel via aria-controls
- Announce state changes via aria-selected on the active tab
Advanced options and variations
- Manual vs. automatic activation
- Manual keeps activation on Enter/Space, which is friendlier for large or slow-loading panels
- Automatic updates the panel as users arrow through tabs, which can feel more fluid
- Vertical tablists
- Set orientation=“vertical” on Tabs and it will apply aria-orientation and Up/Down key handling
- Dynamic tabs
- Registration allows tabs to mount/unmount freely; ensure unique values per tab
- Focus management nuance
- Keep focus on the tab for predictable navigation; only move focus into the panel for specific workflows
Testing accessibility
Automated checks don’t replace manual testing, but they catch regressions.
// Tabs.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tabs, TabList, Tab, TabPanel } from './Tabs';
it('supports keyboard roving and activation', async () => {
render(
<Tabs defaultValue="one" activationMode="manual">
<TabList aria-label="Numbers">
<Tab value="one">One</Tab>
<Tab value="two">Two</Tab>
<Tab value="three">Three</Tab>
</TabList>
<TabPanel value="one">1</TabPanel>
<TabPanel value="two">2</TabPanel>
<TabPanel value="three">3</TabPanel>
</Tabs>
);
const one = screen.getByRole('tab', { name: 'One' });
const two = screen.getByRole('tab', { name: 'Two' });
one.focus();
await userEvent.keyboard('{ArrowRight}');
expect(two).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(two).toHaveAttribute('aria-selected', 'true');
});
Add a11y linters (eslint-plugin-jsx-a11y) and run manual checks with a screen reader to validate announcements and focus behavior.
Common pitfalls to avoid
- Using anchor tags for tabs without preventing navigation
- Making all tabs tabbable (tabIndex=0) instead of using roving tabindex
- Forgetting to synchronize id/aria-controls/aria-labelledby pairs
- Hiding panels with display:none without also setting hidden (or otherwise ensuring they’re not announced)
- Omitting labels for the tablist, leaving screen reader users guessing
Final thoughts
With a small amount of well-placed ARIA and keyboard logic, your Tabs can be both delightful and inclusive. The primitives above are intentionally minimal: compose them with your design system, add animations, or plug in data fetching. Most importantly, keep the semantics and interactions intact so every user can navigate confidently.
Related Posts
Building a Robust React Toast Notification System: Patterns, Code, and UX
Design and implement a fast, accessible React toast system with clean APIs, animations, and promise toasts—complete code, patterns, and pitfalls.
Accessible Focus Management in React: From Routing to Modals
A practical guide to accessible focus management in React: keyboard navigation, skip links, route changes, modals, roving tabIndex, and testing.
Build an Accessible, Dynamic React Breadcrumb Component (React Router v6 + Next.js)
Build an accessible, responsive React breadcrumb with dynamic routes, a11y, SEO, and examples for React Router v6 and Next.js.