typescriptintermediate

Accessible Tabs Component

Build an accessible tabs component with keyboard navigation, ARIA attributes, and focus management.

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

interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
  disabled?: boolean;
}

function Tabs({ tabs, defaultTab }: { tabs: TabItem[]; defaultTab?: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab ?? tabs[0]?.id ?? '');
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  const enabledTabs = tabs.filter((t) => !t.disabled);

  const focusTab = useCallback((id: string) => {
    setActiveTab(id);
    tabRefs.current.get(id)?.focus();
  }, []);

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      const currentIdx = enabledTabs.findIndex((t) => t.id === activeTab);
      let nextIdx = currentIdx;

      switch (e.key) {
        case 'ArrowRight':
          nextIdx = (currentIdx + 1) % enabledTabs.length;
          break;
        case 'ArrowLeft':
          nextIdx = (currentIdx - 1 + enabledTabs.length) % enabledTabs.length;
          break;
        case 'Home':
          nextIdx = 0;
          break;
        case 'End':
          nextIdx = enabledTabs.length - 1;
          break;
        default:
          return;
      }

      e.preventDefault();
      focusTab(enabledTabs[nextIdx].id);
    },
    [activeTab, enabledTabs, focusTab],
  );

  const activeContent = tabs.find((t) => t.id === activeTab)?.content;

  return (
    <div>
      <div role="tablist" className="flex gap-1 border-b border-white/10" onKeyDown={handleKeyDown}>
        {tabs.map((tab) => (
          <button
            key={tab.id}
            role="tab"
            ref={(el) => { if (el) tabRefs.current.set(tab.id, el); }}
            id={`tab-${tab.id}`}
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            aria-disabled={tab.disabled}
            tabIndex={activeTab === tab.id ? 0 : -1}
            onClick={() => !tab.disabled && setActiveTab(tab.id)}
            className={`px-4 py-2 text-sm font-medium transition-colors
              ${
                activeTab === tab.id
                  ? 'text-blue-400 border-b-2 border-blue-400'
                  : tab.disabled
                    ? 'text-gray-600 cursor-not-allowed'
                    : 'text-gray-400 hover:text-white'
              }`}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div
        role="tabpanel"
        id={`panel-${activeTab}`}
        aria-labelledby={`tab-${activeTab}`}
        className="py-4"
      >
        {activeContent}
      </div>
    </div>
  );
}

// Usage:
// <Tabs tabs={[
//   { id: 'code', label: 'Code', content: <CodeView /> },
//   { id: 'preview', label: 'Preview', content: <PreviewPane /> },
//   { id: 'tests', label: 'Tests', content: <TestResults />, disabled: true },
// ]} />

export { Tabs, type TabItem };

Use Cases

  • Tab navigation interfaces
  • Settings panels
  • Code/preview toggle

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.