Tabs
A set of layered sections of content, known as tab panels, that display one panel of content at a time.
🤖 AI Implementation GuideDemo
Automatic Activation (Default)
Tabs are activated automatically when focused with arrow keys.
This is the overview panel content. It provides a general introduction to the product or service.
Keyboard navigation support, ARIA compliant, Automatic and manual activation modes.
Pricing information would be displayed here.
Manual Activation
Tabs require Enter or Space to activate after focusing.
Content for tab one. Press Enter or Space to activate tabs.
Content for tab two.
Content for tab three.
Vertical Orientation
Tabs arranged vertically with Up/Down arrow navigation.
Configure your application settings here.
Manage your profile information.
Set your notification preferences.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tablist | Container | Container for tab elements |
tab | Each tab | Individual tab element |
tabpanel | Panel | Content area for each tab |
WAI-ARIA tablist role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-orientation | tablist | "horizontal" | "vertical" | No | orientation prop |
aria-controls | tab | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | tabpanel | ID reference to associated tab | Yes | Auto-generated |
WAI-ARIA States
aria-selected
Indicates the currently active tab.
| Target | tab element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Tab click, Arrow keys (automatic), Enter/Space (manual) |
| Reference | aria-selected (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the tablist |
| Arrow Right / Arrow Left | Navigate between tabs (horizontal) |
| Arrow Down / Arrow Up | Navigate between tabs (vertical) |
| Home | Move focus to first tab |
| End | Move focus to last tab |
| Enter / Space | Activate tab (manual mode only) |
Source Code
Tabs.tsx
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
export interface TabItem {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
}
export interface TabsProps {
/** Array of tab items */
tabs: TabItem[];
/** Initially selected tab ID */
defaultSelectedId?: string;
/** Orientation of the tabs */
orientation?: 'horizontal' | 'vertical';
/** Activation mode */
activation?: 'automatic' | 'manual';
/** Callback when tab selection changes */
onSelectionChange?: (tabId: string) => void;
/** Additional CSS class */
className?: string;
}
export function Tabs({
tabs,
defaultSelectedId,
orientation = 'horizontal',
activation = 'automatic',
onSelectionChange,
className = '',
}: TabsProps): React.ReactElement {
// availableTabsの安定化(パフォーマンス最適化)
const availableTabs = useMemo(() => tabs.filter((tab) => !tab.disabled), [tabs]);
const initialTab = defaultSelectedId
? availableTabs.find((tab) => tab.id === defaultSelectedId)
: availableTabs[0];
const [selectedId, setSelectedId] = useState(initialTab?.id || availableTabs[0]?.id);
const [focusedIndex, setFocusedIndex] = useState(() => {
const index = availableTabs.findIndex((tab) => tab.id === initialTab?.id);
return index >= 0 ? index : 0;
});
const tablistRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const tablistId = useId();
const handleTabSelection = useCallback(
(tabId: string) => {
setSelectedId(tabId);
onSelectionChange?.(tabId);
},
[onSelectionChange]
);
const handleTabFocus = useCallback(
(index: number) => {
setFocusedIndex(index);
const tab = availableTabs[index];
if (tab) {
tabRefs.current.get(tab.id)?.focus();
}
},
[availableTabs]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key } = event;
const target = event.target;
if (
!tablistRef.current ||
!(target instanceof Node) ||
!tablistRef.current.contains(target)
) {
return;
}
let newIndex = focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case 'ArrowRight':
case 'ArrowDown':
newIndex = (focusedIndex + 1) % availableTabs.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
case 'ArrowUp':
newIndex = (focusedIndex - 1 + availableTabs.length) % availableTabs.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableTabs.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
if (activation === 'manual') {
const focusedTab = availableTabs[focusedIndex];
if (focusedTab) {
handleTabSelection(focusedTab.id);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== focusedIndex) {
handleTabFocus(newIndex);
if (activation === 'automatic') {
const newTab = availableTabs[newIndex];
if (newTab) {
handleTabSelection(newTab.id);
}
}
}
}
},
[focusedIndex, availableTabs, activation, handleTabSelection, handleTabFocus]
);
// フォーカス同期(Activation mode考慮)
useEffect(() => {
if (activation === 'manual') {
// Manual: tabsの変更により範囲外になった場合のみ修正
if (focusedIndex >= availableTabs.length) {
setFocusedIndex(Math.max(0, availableTabs.length - 1));
}
return;
}
// Automatic: 選択に追従
const selectedIndex = availableTabs.findIndex((tab) => tab.id === selectedId);
if (selectedIndex >= 0 && selectedIndex !== focusedIndex) {
setFocusedIndex(selectedIndex);
}
}, [selectedId, availableTabs, activation, focusedIndex]);
const containerClass = `apg-tabs ${
orientation === 'vertical' ? 'apg-tabs--vertical' : 'apg-tabs--horizontal'
} ${className}`.trim();
const tablistClass = `apg-tablist ${
orientation === 'vertical' ? 'apg-tablist--vertical' : 'apg-tablist--horizontal'
}`;
return (
<div className={containerClass}>
<div
ref={tablistRef}
role="tablist"
aria-orientation={orientation}
className={tablistClass}
onKeyDown={handleKeyDown}
>
{tabs.map((tab) => {
const isSelected = tab.id === selectedId;
// APG準拠: Manual Activationではフォーカス位置でtabIndexを制御
const isFocusTarget =
activation === 'manual' ? tab.id === availableTabs[focusedIndex]?.id : isSelected;
const tabIndex = tab.disabled ? -1 : isFocusTarget ? 0 : -1;
const tabPanelId = `${tablistId}-panel-${tab.id}`;
const tabClass = `apg-tab ${
orientation === 'vertical' ? 'apg-tab--vertical' : 'apg-tab--horizontal'
} ${isSelected ? 'apg-tab--selected' : ''} ${
tab.disabled ? 'apg-tab--disabled' : ''
}`.trim();
return (
<button
key={tab.id}
ref={(el) => {
if (el) {
tabRefs.current.set(tab.id, el);
} else {
tabRefs.current.delete(tab.id);
}
}}
role="tab"
type="button"
id={`${tablistId}-tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={isSelected ? tabPanelId : undefined}
tabIndex={tabIndex}
disabled={tab.disabled}
className={tabClass}
onClick={() => !tab.disabled && handleTabSelection(tab.id)}
>
<span className="apg-tab-label">{tab.label}</span>
</button>
);
})}
</div>
<div className="apg-tabpanels">
{tabs.map((tab) => {
const isSelected = tab.id === selectedId;
const tabPanelId = `${tablistId}-panel-${tab.id}`;
return (
<div
key={tab.id}
role="tabpanel"
id={tabPanelId}
aria-labelledby={`${tablistId}-tab-${tab.id}`}
hidden={!isSelected}
className={`apg-tabpanel ${
isSelected ? 'apg-tabpanel--active' : 'apg-tabpanel--inactive'
}`}
tabIndex={isSelected ? 0 : -1}
>
{tab.content}
</div>
);
})}
</div>
</div>
);
}
export default Tabs; Usage
Example
import { Tabs } from './Tabs';
const tabs = [
{ id: 'tab1', label: 'First', content: 'First panel content' },
{ id: 'tab2', label: 'Second', content: 'Second panel content' },
{ id: 'tab3', label: 'Third', content: 'Third panel content' }
];
function App() {
return (
<Tabs
tabs={tabs}
defaultSelectedId="tab1"
onSelectionChange={(id) => console.log('Tab changed:', id)}
/>
);
} API
Tabs Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | TabItem[] | required | Array of tab items |
defaultSelectedId | string | first tab | ID of the initially selected tab |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Tab layout direction |
activation | 'automatic' | 'manual' | 'automatic' | How tabs are activated |
onSelectionChange | (tabId: string) => void | - | Callback when tab changes |
TabItem Interface
Types
interface TabItem {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
ArrowRight/Left | Moves focus between tabs (horizontal) |
ArrowDown/Up | Moves focus between tabs (vertical orientation) |
Home/End | Moves focus to first/last tab |
Loop navigation | Arrow keys loop from last to first and vice versa |
Disabled skip | Skips disabled tabs during navigation |
Automatic activation | Tab panel changes on focus (default mode) |
Manual activation | Enter/Space required to activate tab |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="tablist" | Container has tablist role |
role="tab" | Each tab button has tab role |
role="tabpanel" | Content panel has tabpanel role |
aria-selected | Selected tab has aria-selected="true" |
aria-controls | Tab references its panel via aria-controls |
aria-labelledby | Panel references its tab via aria-labelledby |
aria-orientation | Reflects horizontal/vertical orientation |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabIndex=0 | Selected tab has tabIndex=0 |
tabIndex=-1 | Non-selected tabs have tabIndex=-1 |
Tab to panel | Tab key moves focus from tablist to panel |
Panel focusable | Panel has tabIndex=0 for focus |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
Tabs.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Tabs, type TabItem } from './Tabs';
// Test tab data
const defaultTabs: TabItem[] = [
{ id: 'tab1', label: 'Tab 1', content: 'Content 1' },
{ id: 'tab2', label: 'Tab 2', content: 'Content 2' },
{ id: 'tab3', label: 'Tab 3', content: 'Content 3' },
];
const tabsWithDisabled: TabItem[] = [
{ id: 'tab1', label: 'Tab 1', content: 'Content 1' },
{ id: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true },
{ id: 'tab3', label: 'Tab 3', content: 'Content 3' },
];
describe('Tabs', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction (Horizontal)', () => {
describe('Automatic Activation', () => {
it('moves to and selects next tab with ArrowRight', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveFocus();
expect(tab2).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
});
it('moves to and selects previous tab with ArrowLeft', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} defaultSelectedId="tab2" />);
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
tab2.focus();
await user.keyboard('{ArrowLeft}');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveFocus();
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
it('loops from last to first with ArrowRight', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} defaultSelectedId="tab3" />);
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
tab3.focus();
await user.keyboard('{ArrowRight}');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveFocus();
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
it('loops from first to last with ArrowLeft', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowLeft}');
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
expect(tab3).toHaveFocus();
expect(tab3).toHaveAttribute('aria-selected', 'true');
});
it('moves to and selects first tab with Home', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} defaultSelectedId="tab3" />);
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
tab3.focus();
await user.keyboard('{Home}');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveFocus();
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
it('moves to and selects last tab with End', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{End}');
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
expect(tab3).toHaveFocus();
expect(tab3).toHaveAttribute('aria-selected', 'true');
});
it('skips disabled tab and moves to next enabled tab', async () => {
const user = userEvent.setup();
render(<Tabs tabs={tabsWithDisabled} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
// Tab 2 is skipped, moves to Tab 3
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
expect(tab3).toHaveFocus();
expect(tab3).toHaveAttribute('aria-selected', 'true');
});
});
describe('Manual Activation', () => {
it('moves focus with arrow keys but does not switch panel', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="manual" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveFocus();
// Panel does not switch
expect(tab1).toHaveAttribute('aria-selected', 'true');
expect(tab2).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1');
});
it('selects focused tab with Enter', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="manual" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
await user.keyboard('{Enter}');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
});
it('selects focused tab with Space', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="manual" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
await user.keyboard(' ');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
});
});
});
describe('APG: Keyboard Interaction (Vertical)', () => {
it('moves to and selects next tab with ArrowDown', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} orientation="vertical" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowDown}');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveFocus();
expect(tab2).toHaveAttribute('aria-selected', 'true');
});
it('moves to and selects previous tab with ArrowUp', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} orientation="vertical" defaultSelectedId="tab2" />);
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
tab2.focus();
await user.keyboard('{ArrowUp}');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveFocus();
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
it('loops with ArrowDown/Up', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} orientation="vertical" defaultSelectedId="tab3" />);
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
tab3.focus();
await user.keyboard('{ArrowDown}');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveFocus();
});
});
describe('APG: ARIA Attributes', () => {
it('tablist has role="tablist"', () => {
render(<Tabs tabs={defaultTabs} />);
expect(screen.getByRole('tablist')).toBeInTheDocument();
});
it('each tab has role="tab"', () => {
render(<Tabs tabs={defaultTabs} />);
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
});
it('each panel has role="tabpanel"', () => {
render(<Tabs tabs={defaultTabs} />);
// Only selected panel is displayed
expect(screen.getByRole('tabpanel')).toBeInTheDocument();
});
it('selected tab has aria-selected="true", unselected have "false"', () => {
render(<Tabs tabs={defaultTabs} />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
});
it('selected tab aria-controls matches panel id', () => {
render(<Tabs tabs={defaultTabs} />);
const selectedTab = screen.getByRole('tab', { name: 'Tab 1' });
const tabpanel = screen.getByRole('tabpanel');
const ariaControls = selectedTab.getAttribute('aria-controls');
expect(ariaControls).toBe(tabpanel.id);
});
it('panel aria-labelledby matches tab id', () => {
render(<Tabs tabs={defaultTabs} />);
const selectedTab = screen.getByRole('tab', { name: 'Tab 1' });
const tabpanel = screen.getByRole('tabpanel');
const ariaLabelledby = tabpanel.getAttribute('aria-labelledby');
expect(ariaLabelledby).toBe(selectedTab.id);
});
it('aria-orientation reflects orientation prop', () => {
const { rerender } = render(<Tabs tabs={defaultTabs} />);
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal');
rerender(<Tabs tabs={defaultTabs} orientation="vertical" />);
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
});
});
describe('APG: Focus Management (Roving Tabindex)', () => {
it('Automatic: only selected tab has tabIndex=0', () => {
render(<Tabs tabs={defaultTabs} />);
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('tabIndex', '0');
expect(tabs[1]).toHaveAttribute('tabIndex', '-1');
expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
});
it('Manual: focused tab has tabIndex=0', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} activation="manual" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
const tabs = screen.getAllByRole('tab');
// In Manual, focused tab (not selected) has tabIndex=0
expect(tabs[0]).toHaveAttribute('tabIndex', '-1');
expect(tabs[1]).toHaveAttribute('tabIndex', '0');
expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
});
it('can move to tabpanel with Tab key', async () => {
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.tab();
expect(screen.getByRole('tabpanel')).toHaveFocus();
});
it('tabpanel is focusable with tabIndex=0', () => {
render(<Tabs tabs={defaultTabs} />);
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('tabIndex', '0');
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<Tabs tabs={defaultTabs} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('can specify initial selected tab with defaultSelectedId', () => {
render(<Tabs tabs={defaultTabs} defaultSelectedId="tab2" />);
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2');
});
it('calls onSelectionChange when tab is selected', async () => {
const handleSelectionChange = vi.fn();
const user = userEvent.setup();
render(<Tabs tabs={defaultTabs} onSelectionChange={handleSelectionChange} />);
await user.click(screen.getByRole('tab', { name: 'Tab 2' }));
expect(handleSelectionChange).toHaveBeenCalledWith('tab2');
});
});
describe('Edge Cases', () => {
it('selects first tab when defaultSelectedId does not exist', () => {
render(<Tabs tabs={defaultTabs} defaultSelectedId="nonexistent" />);
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveAttribute('aria-selected', 'true');
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
const { container } = render(<Tabs tabs={defaultTabs} className="custom-tabs" />);
const tabsContainer = container.firstChild as HTMLElement;
expect(tabsContainer).toHaveClass('custom-tabs');
});
});
}); Resources
- WAI-ARIA APG: Tabs Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist