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.

ASOasis
8 min read
Building an Accessible React Tabs Component in React (WAI‑ARIA, Keyboard, TypeScript)

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 multifactor 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