react nextjs accessibility patterns

Building Accessible Tabs in React Without Radix UI

This guide provides a step-by-step implementation for WAI-ARIA compliant tab components in React 18+, bypassing third-party abstractions while maintaining strict keyboard navigation, screen reader compatibility, and deterministic state synchronization. The architecture directly maps to WCAG 2.2 Success Criteria: 1.3.1 Info and Relationships (semantic role mapping), 2.1.1 Keyboard (full arrow/tab traversal), 2.4.3 Focus Order (logical DOM sequence), and 4.1.2 Name, Role, Value (explicit aria-* attribute binding). Manual implementation aligns with established React & Next.js Accessibility Patterns for engineering teams requiring deterministic control over focus management, render cycles, and bundle size.

Why Build Tabs Without a Library

Reaching for Radix or a similar kit is the right default for most teams—but not every team. A hand-rolled tablist makes sense when you need to drop a dependency from a tightly budgeted bundle, when your design demands behavior the library does not expose (vertical orientation, lazy-mounted panels, deep-linked active state), or when you simply want every line of ARIA logic to be auditable in your own repository. The tab pattern is one of the few composite widgets where building from native <button> elements is genuinely tractable: there are no portals, no positioning math, and the keyboard model is fully specified by the WAI-ARIA Authoring Practices. That is precisely why it is the canonical "build it yourself" exercise referenced in Accessible Component Libraries in React.

Prerequisites

Before implementing, ensure your environment and mental model are in place:

  • React 18 or later. The code relies on useId for collision-free relationship IDs and on the stable concurrent renderer.
  • A keyboard-first testing setup. You will validate with the keyboard only, plus NVDA + Firefox and VoiceOver + Safari. Install the axe DevTools browser extension for the automated baseline.
  • Familiarity with roving tabindex. Only the active tab is in the document tab order (tabIndex={0}); the rest are -1 and reachable solely via arrow keys. If that pattern is new, review the keyboard sections in the parent libraries guide first.
  • A clear distinction between focus and activation. This implementation uses manual activation: arrow keys move focus, and Enter/Space (or click) selects. Understanding that split is essential to the rest of the guide.

Core ARIA Architecture for Tabs

The accessibility tree must explicitly declare the tab widget structure. Screen readers rely on role inheritance and ID cross-referencing to parse tab relationships correctly.

Structural Requirements

  • Container: role="tablist" with aria-orientation="horizontal"
  • Triggers: <button> elements with role="tab", aria-selected, and aria-controls
  • Panels: role="tabpanel" with aria-labelledby referencing the corresponding tab id
  • Constraint: Never nest interactive elements (<a>, <button>, <input>) inside tab triggers. This breaks native activation semantics and creates nested focus traps.

Base JSX Structure

<>
  <div role="tablist" aria-orientation="horizontal">
    <button role="tab" aria-selected={true} aria-controls="panel-1" id="tab-1">
      Tab 1
    </button>
    <button role="tab" aria-selected={false} aria-controls="panel-2" id="tab-2" tabIndex={-1}>
      Tab 2
    </button>
  </div>

  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabIndex={0}>
    Panel 1 Content
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" tabIndex={0} hidden>
    Panel 2 Content
  </div>
</>

Debugging Workflow: Run axe-core via browser extension or CLI. Verify that aria-controls values exactly match panel id attributes. In VoiceOver/NVDA, navigate to the tablist and confirm the screen reader announces "Tab 1" and reads the selected state correctly. When evaluating whether to build custom or integrate Accessible Component Libraries in React, validate that your baseline architecture passes these role-exposure checks before adding business logic.

Keyboard Navigation & Focus Management

Tab traversal must follow the WAI-ARIA Authoring Practices for manual activation mode. Focus remains within the tablist until the user explicitly presses Tab to exit.

Event Handling Logic

  • ArrowRight / ArrowLeft: Cycles focus through tabs in reading order. Wraps at boundaries.
  • Home / End: Jumps focus to the first or last tab.
  • Enter / Space: Activates the focused tab (manual activation).
  • Tab: Exits the tablist and moves focus to the next focusable element in the document flow.

Implementation

import { useRef } from 'react';

export function useTabKeyNav(tabCount: number, activeIndex: number, setActiveIndex: (i: number) => void) {
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const focusTab = (index: number) => {
    tabRefs.current[index]?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
    const { key } = event;
    let nextIndex = currentIndex;

    if (key === 'ArrowRight') {
      event.preventDefault();
      nextIndex = (currentIndex + 1) % tabCount;
    } else if (key === 'ArrowLeft') {
      event.preventDefault();
      nextIndex = (currentIndex - 1 + tabCount) % tabCount;
    } else if (key === 'Home') {
      event.preventDefault();
      nextIndex = 0;
    } else if (key === 'End') {
      event.preventDefault();
      nextIndex = tabCount - 1;
    }

    if (nextIndex !== currentIndex) {
      setActiveIndex(nextIndex);
      focusTab(nextIndex);
    }
  };

  return { tabRefs, handleKeyDown };
}

Testing Configuration: Disable mouse input during QA. Verify that ArrowRight/ArrowLeft moves focus without triggering panel content re-renders prematurely. Confirm the browser's native focus ring remains visible and meets WCAG 2.4.7 Focus Visible contrast ratios. Use Playwright's page.keyboard.press() to automate traversal validation in CI.

Supporting Vertical Orientation

A common reason to leave Radix behind is needing a vertical tablist. The keyboard contract changes with orientation: when aria-orientation="vertical", ArrowUp/ArrowDown drive traversal and the horizontal arrows should be ignored. Parameterize the hook rather than branching at every call site:

type Orientation = 'horizontal' | 'vertical';

function nextKeyFor(orientation: Orientation) {
  return orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
}
function prevKeyFor(orientation: Orientation) {
  return orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
}

Wiring aria-orientation to the same orientation prop that selects the arrow keys keeps the announced model and the actual behavior in lockstep—a 2.1.1 and 1.3.1 requirement that automated tools rarely catch.

State Synchronization & Screen Reader Announcements

React's reconciliation cycle can desynchronize ARIA attributes if state updates are not tightly coupled to DOM visibility. Inactive panels must be removed from the accessibility tree to prevent screen readers from traversing hidden content.

State & Visibility Strategy

  • Track activeIndex via useState. Derive aria-selected and tabIndex from this single source of truth.
  • Hide inactive panels using the native hidden attribute or aria-hidden="true" combined with display: none. Avoid visibility: hidden as it preserves layout space and can leak focus.
  • Implement a custom hook to encapsulate prop generation, ensuring consistent ARIA mapping across component instances.

useAccessibleTabs Hook

import { useState, useCallback, useRef } from 'react';

export function useAccessibleTabs(tabCount: number) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const getTablistProps = useCallback(() => ({
    role: 'tablist' as const,
    'aria-orientation': 'horizontal' as const,
  }), []);

  const getTabProps = useCallback((index: number) => ({
    role: 'tab' as const,
    id: `tab-${index}`,
    'aria-controls': `panel-${index}`,
    'aria-selected': index === activeIndex,
    tabIndex: index === activeIndex ? 0 : -1,
    ref: (el: HTMLButtonElement | null) => { tabRefs.current[index] = el; },
    onClick: () => setActiveIndex(index),
    onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        setActiveIndex(index);
      }
    },
  }), [activeIndex]);

  const getPanelProps = useCallback((index: number) => ({
    role: 'tabpanel' as const,
    id: `panel-${index}`,
    'aria-labelledby': `tab-${index}`,
    tabIndex: 0,
    hidden: index !== activeIndex,
  }), [activeIndex]);

  return {
    activeIndex,
    setActiveIndex,
    tabRefs,
    getTablistProps,
    getTabProps,
    getPanelProps,
  };
}

Testing Note: Inspect the accessibility tree in Chrome DevTools. Confirm that only the active panel exists in the tree. Rapidly press arrow keys and verify screen readers do not queue overlapping announcements. If dynamic content loads inside panels, wrap the panel content in an aria-live="polite" region and debounce state updates to prevent speech queue overload.

Avoiding ID Collisions With useId

The hook above hardcodes tab-${index}, which collides the moment two tab groups appear on one page—aria-labelledby then resolves to the wrong panel. In production, derive a stable prefix from useId so every instance is independently addressable:

import { useId } from 'react';

// inside useAccessibleTabs:
const base = useId(); // e.g. ":r3:"
const tabId = (i: number) => `${base}-tab-${i}`;
const panelId = (i: number) => `${base}-panel-${i}`;

Because useId produces identical values on server and client, this also prevents Next.js App Router hydration mismatches that would otherwise sever the aria-controls linkage on first paint.

AccessibleTabs Component Implementation

'use client';

import React from 'react';
import { useAccessibleTabs } from './useAccessibleTabs';

interface TabData {
  label: string;
  content: React.ReactNode;
}

interface AccessibleTabsProps {
  tabs: TabData[];
}

export function AccessibleTabs({ tabs }: AccessibleTabsProps) {
  const {
    activeIndex,
    setActiveIndex,
    tabRefs,
    getTablistProps,
    getTabProps,
    getPanelProps,
  } = useAccessibleTabs(tabs.length);

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const { key } = e;
    const count = tabs.length;

    if (key === 'ArrowRight') {
      e.preventDefault();
      const next = (activeIndex + 1) % count;
      setActiveIndex(next);
      tabRefs.current[next]?.focus();
    } else if (key === 'ArrowLeft') {
      e.preventDefault();
      const prev = (activeIndex - 1 + count) % count;
      setActiveIndex(prev);
      tabRefs.current[prev]?.focus();
    } else if (key === 'Home') {
      e.preventDefault();
      setActiveIndex(0);
      tabRefs.current[0]?.focus();
    } else if (key === 'End') {
      e.preventDefault();
      setActiveIndex(count - 1);
      tabRefs.current[count - 1]?.focus();
    }
  };

  return (
    <div>
      <div {...getTablistProps()} onKeyDown={handleKeyDown}>
        {tabs.map((tab, index) => (
          <button key={index} {...getTabProps(index)}>
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div key={index} {...getPanelProps(index)}>
          {tab.content}
        </div>
      ))}
    </div>
  );
}

How to Verify

A tab widget is only "accessible" once it survives both automated and manual checks. Run this sequence before shipping:

  1. Automated tree check (axe). Run the axe DevTools extension or @axe-core/playwright on the rendered component. It will flag mismatched aria-controls/id pairs and missing accessible names—your first guardrail for 1.3.1 and 4.1.2.
  2. Accessibility tree inspection. In Chrome DevTools → Accessibility, confirm only the active panel is present and the active tab reports selected: true. Hidden panels must be absent from the tree, not merely visually clipped.
  3. Keyboard-only walkthrough. Tab into the list, confirm focus lands on the active tab only, then verify ArrowLeft/ArrowRight/Home/End move focus and Enter/Space activate. Press Tab once more and confirm focus exits cleanly into the active panel—satisfying 2.4.3.
  4. Screen reader pass. With NVDA + Firefox and VoiceOver + Safari, confirm each tab announces its label, position ("1 of 3"), and selected state, and that activating a tab announces the new panel without double-speaking.

If any step fails, fix it before adding business logic—layering features onto a broken accessibility tree only makes the regression harder to isolate.

Common Pitfalls

  1. Using <div> or <span> for tab triggers: Breaks native keyboard activation (Enter/Space) and requires manual tabIndex/role patching. Always use <button>.
  2. Desynchronized aria-selected and visual state: Causes screen readers to announce incorrect active tabs. Derive both from a single activeIndex state variable.
  3. Hiding panels with visibility: hidden: Preserves layout and keeps elements in the accessibility tree. Use hidden or display: none to fully remove them from the a11y tree.
  4. Missing or low-contrast focus outlines: Violates WCAG 2.4.7. Implement explicit :focus-visible styles with a minimum 3:1 contrast ratio against the background.
  5. Over-announcing with aria-live: Rapid state changes flood screen reader queues. Debounce announcements or rely on native aria-selected state changes, which are announced automatically by modern assistive technology.
  6. Hardcoded relationship IDs: Static tab-0 / panel-0 IDs collide across multiple tab groups on one page. Derive them from useId so each instance is uniquely addressable and SSR-safe.

Conclusion

Building tabs without Radix is a deliberate trade: you accept ownership of the keyboard model and ARIA wiring in exchange for zero dependency weight and total control over behavior. The pattern is tractable precisely because the WAI-ARIA Authoring Practices specify it completely—a single activeIndex source of truth, roving tabindex, useId-derived relationships, and manual activation cover the entire 1.3.1/2.1.1/2.4.3/4.1.2 surface. Encapsulate the prop generation in a hook, verify against both axe and a real screen reader, and you have a component as robust as any library's, with none of the maintenance surprises. For closely related composite-widget keyboard models, the same discipline applies to dropdowns and menus.

Frequently Asked Questions

Should I use automatic or manual activation for React tabs? Manual activation (click/Enter/Space) is the standard for performance and screen reader compatibility, particularly when panels contain heavy content, forms, or lazy-loaded data. Automatic activation requires aggressive debouncing and frequently causes unexpected viewport jumps or layout shifts.

How do I handle focus when a tab panel contains interactive elements? Focus must remain on the tab button until the user explicitly presses Tab to move into the panel content. Do not auto-focus the first interactive element inside the panel unless explicitly mandated by UX. Auto-focusing disrupts standard keyboard navigation expectations and violates predictable focus order.

Can I use this pattern in Next.js App Router? Yes, but the component must be marked with 'use client'. Tabs require browser-side event listeners, useRef DOM access, and client-side state management. Server components cannot handle interactive tab logic or client-side focus management directly.

How do I support a vertical tab layout accessibly? Set aria-orientation="vertical" on the tablist and switch the traversal keys to ArrowUp/ArrowDown. Keep the orientation prop as the single source for both the ARIA attribute and the key handler so the announced model never drifts from the actual behavior.

Why use useId instead of array indices for tab and panel IDs? Array indices collide when more than one tab group renders on a page, breaking aria-controls/aria-labelledby resolution. useId yields a unique, SSR-stable prefix per instance, which also avoids hydration mismatches in the Next.js App Router.