Accordion
垂直に積み重ねられたインタラクティブな見出しのセット。各見出しをクリックするとコンテンツセクションが展開されます。
🤖 AI 実装ガイドデモ
単一展開(デフォルト)
一度に1つのパネルのみ展開可能です。新しいパネルを開くと、前に開いていたパネルは閉じます。
Use accordions when you need to organize content into collapsible sections. This helps reduce visual clutter while keeping information accessible. They are particularly useful for FAQs, settings panels, and navigation menus.
Accordions must be keyboard accessible and properly announce their expanded/collapsed state to screen readers. Each header should be a proper heading element, and the panel should be associated with its header via aria-controls and aria-labelledby.
複数展開
allowMultiple プロパティを使用すると、複数のパネルを同時に展開できます。
Content for section one. With allowMultiple enabled, multiple sections can be open at the same time.
Content for section two. Try opening this while section one is still open.
Content for section three. All three sections can be expanded simultaneously.
無効なアイテム付き
個々のアコーディオンアイテムを無効化できます。キーボードナビゲーションは無効なアイテムを自動的にスキップします。
This section can be expanded and collapsed normally.
This content is not accessible because the section is disabled.
This section can also be expanded. Notice that arrow key navigation skips the disabled section.
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
heading | ヘッダーラッパー (h2-h6) | アコーディオントリガーボタンを含む |
button | ヘッダートリガー | パネルの表示/非表示を切り替える対話要素 |
region | パネル (オプション) | ヘッダーと関連付けられたコンテンツエリア (6個以上のパネルでは省略) |
WAI-ARIA Accordion Pattern (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 設定方法 |
|---|---|---|---|---|
aria-level | heading | 2 - 6 | はい | headingLevel プロパティ |
aria-controls | button | 関連付けられたパネルへのID参照 | はい | 自動生成 |
aria-labelledby | region (パネル) | ヘッダーボタンへのID参照 | はい (regionを使用する場合) | 自動生成 |
WAI-ARIA ステート
aria-expanded
アコーディオンパネルが展開されているか折り畳まれているかを示します。
| 対象 | button 要素 |
| 値 | true | false |
| 必須 | はい |
| 変更トリガー | クリック、Enter、Space |
| リファレンス | aria-expanded (opens in new tab) |
aria-disabled
アコーディオンヘッダーが無効化されているかどうかを示します。
| 対象 | button 要素 |
| 値 | true | false |
| 必須 | いいえ (無効化する場合のみ) |
| リファレンス | aria-disabled (opens in new tab) |
キーボードサポート
| キー | アクション |
|---|---|
| Tab | 次のフォーカス可能な要素にフォーカスを移動 |
| Space / Enter | フォーカスされたアコーディオンヘッダーの展開/折り畳みを切り替え |
| Arrow Down | 次のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| Arrow Up | 前のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| Home | 最初のアコーディオンヘッダーにフォーカスを移動 (オプション) |
| End | 最後のアコーディオンヘッダーにフォーカスを移動 (オプション) |
矢印キーによるナビゲーションはオプションですが推奨されます。フォーカスはリストの端でループしません。
ソースコード
Accordion.tsx
import { useCallback, useId, useRef, useState } from 'react';
/**
* Accordion item configuration
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
export interface AccordionItem {
/** Unique identifier for the item */
id: string;
/** Content displayed in the accordion header button */
header: React.ReactNode;
/** Content displayed in the collapsible panel */
content: React.ReactNode;
/** When true, the item cannot be expanded/collapsed */
disabled?: boolean;
/** When true, the panel is expanded on initial render */
defaultExpanded?: boolean;
}
/**
* Props for the Accordion component
*
* @example
* ```tsx
* const items = [
* { id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
* { id: 'section2', header: 'Section 2', content: 'Content 2' },
* ];
*
* <Accordion
* items={items}
* headingLevel={3}
* allowMultiple={false}
* onExpandedChange={(ids) => console.log('Expanded:', ids)}
* />
* ```
*/
export interface AccordionProps {
/**
* Array of accordion items to display
* Each item requires an id, header, and content
*/
items: AccordionItem[];
/**
* Allow multiple panels to be expanded simultaneously
* @default false
*/
allowMultiple?: boolean;
/**
* Heading level for accessibility (h2-h6)
* Should match the document outline hierarchy
* @default 3
*/
headingLevel?: 2 | 3 | 4 | 5 | 6;
/**
* Enable arrow key navigation between accordion headers
* When enabled: Arrow Up/Down, Home, End keys navigate between headers
* @default true
*/
enableArrowKeys?: boolean;
/**
* Callback fired when the expanded panels change
* @param expandedIds - Array of currently expanded item IDs
*/
onExpandedChange?: (expandedIds: string[]) => void;
/**
* Additional CSS class to apply to the accordion container
* @default ""
*/
className?: string;
}
export function Accordion({
items,
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
onExpandedChange,
className = '',
}: AccordionProps): React.ReactElement {
const instanceId = useId();
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
// Initialize with defaultExpanded items
const [expandedIds, setExpandedIds] = useState<string[]>(() =>
items.filter((item) => item.defaultExpanded && !item.disabled).map((item) => item.id)
);
const availableItems = items.filter((item) => !item.disabled);
const handleToggle = useCallback(
(itemId: string) => {
const item = items.find((i) => i.id === itemId);
if (item?.disabled) return;
let newExpandedIds: string[];
const isCurrentlyExpanded = expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
newExpandedIds = expandedIds.filter((id) => id !== itemId);
} else {
if (allowMultiple) {
newExpandedIds = [...expandedIds, itemId];
} else {
newExpandedIds = [itemId];
}
}
setExpandedIds(newExpandedIds);
onExpandedChange?.(newExpandedIds);
},
[expandedIds, allowMultiple, items, onExpandedChange]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent, currentItemId: string) => {
if (!enableArrowKeys) return;
const currentIndex = availableItems.findIndex((item) => item.id === currentItemId);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case 'ArrowDown':
// Move to next, but don't wrap (APG compliant)
if (currentIndex < availableItems.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case 'ArrowUp':
// Move to previous, but don't wrap (APG compliant)
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = availableItems.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
const newItem = availableItems[newIndex];
if (newItem && buttonRefs.current[newItem.id]) {
buttonRefs.current[newItem.id]?.focus();
}
}
}
},
[enableArrowKeys, availableItems]
);
// Use role="region" only for 6 or fewer panels (APG recommendation)
const useRegion = items.length <= 6;
// Dynamic heading component with proper typing
const headingTagMap = {
2: 'h2',
3: 'h3',
4: 'h4',
5: 'h5',
6: 'h6',
} as const;
const HeadingTag = headingTagMap[headingLevel];
return (
<div className={`apg-accordion ${className}`.trim()}>
{items.map((item) => {
const headerId = `${instanceId}-header-${item.id}`;
const panelId = `${instanceId}-panel-${item.id}`;
const isExpanded = expandedIds.includes(item.id);
const itemClass = `apg-accordion-item ${
isExpanded ? 'apg-accordion-item--expanded' : ''
} ${item.disabled ? 'apg-accordion-item--disabled' : ''}`.trim();
const triggerClass = `apg-accordion-trigger ${
isExpanded ? 'apg-accordion-trigger--expanded' : ''
}`.trim();
const iconClass = `apg-accordion-icon ${
isExpanded ? 'apg-accordion-icon--expanded' : ''
}`.trim();
const panelClass = `apg-accordion-panel ${
isExpanded ? 'apg-accordion-panel--expanded' : 'apg-accordion-panel--collapsed'
}`.trim();
return (
<div key={item.id} className={itemClass}>
<HeadingTag className="apg-accordion-header">
<button
ref={(el) => {
buttonRefs.current[item.id] = el;
}}
type="button"
id={headerId}
aria-expanded={isExpanded}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
className={triggerClass}
onClick={() => handleToggle(item.id)}
onKeyDown={(e) => handleKeyDown(e, item.id)}
>
<span className="apg-accordion-trigger-content">{item.header}</span>
<span className={iconClass} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</HeadingTag>
<div
role={useRegion ? 'region' : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
className={panelClass}
>
<div className="apg-accordion-panel-content">{item.content}</div>
</div>
</div>
);
})}
</div>
);
}
export default Accordion; 使い方
Example
import { Accordion } from './Accordion';
const items = [
{
id: 'section1',
header: 'First Section',
content: 'Content for the first section...',
defaultExpanded: true,
},
{
id: 'section2',
header: 'Second Section',
content: 'Content for the second section...',
},
];
function App() {
return (
<Accordion
items={items}
headingLevel={3}
allowMultiple={false}
onExpandedChange={(ids) => console.log('Expanded:', ids)}
/>
);
} API
AccordionProps
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
items | AccordionItem[] | 必須 | アコーディオンアイテムの配列 |
allowMultiple | boolean | false | 複数のパネルの同時展開を許可 |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | アクセシビリティ用の見出しレベル |
enableArrowKeys | boolean | true | 矢印キーナビゲーションを有効化 |
onExpandedChange | (ids: string[]) => void | - | 展開状態が変更されたときのコールバック |
className | string | "" | 追加のCSSクラス |
AccordionItem
| プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | アイテムの一意の識別子 |
header | ReactNode | はい | アコーディオンヘッダーのコンテンツ |
content | ReactNode | はい | アコーディオンパネルのコンテンツ |
disabled | boolean | いいえ | アコーディオンアイテムを無効化 |
defaultExpanded | boolean | いいえ | 初期の展開状態 |
テスト
テストは、キーボード操作、ARIA属性、アクセシビリティ要件におけるAPG準拠を検証します。
テストカテゴリ
高優先度: APG キーボード操作
| テスト | 説明 |
|---|---|
Enter キー | フォーカスされたパネルを展開/折り畳み |
Space キー | フォーカスされたパネルを展開/折り畳み |
ArrowDown | 次のヘッダーにフォーカスを移動 |
ArrowUp | 前のヘッダーにフォーカスを移動 |
Home | 最初のヘッダーにフォーカスを移動 |
End | 最後のヘッダーにフォーカスを移動 |
ループなし | フォーカスは端で停止 (ループしない) |
無効化スキップ | ナビゲーション中に無効化されたヘッダーをスキップ |
高優先度: APG ARIA 属性
| テスト | 説明 |
|---|---|
aria-expanded | ヘッダーボタンが展開/折り畳み状態を反映 |
aria-controls | ヘッダーが aria-controls でパネルを参照 |
aria-labelledby | パネルが aria-labelledby でヘッダーを参照 |
role="region" | パネルに region ロール (6個以下のパネル) |
region なし (7個以上) | 7個以上のパネルの場合、region ロールを省略 |
aria-disabled | 無効化された項目に aria-disabled="true" |
高優先度: 見出し構造
| テスト | 説明 |
|---|---|
headingLevel プロパティ | 正しい見出し要素を使用 (h2, h3, など) |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe 違反 | WCAG 2.1 AA 違反なし (jest-axe 経由) |
低優先度: プロパティと振る舞い
| テスト | 説明 |
|---|---|
allowMultiple | 単一または複数の展開を制御 |
defaultExpanded | 初期展開状態を設定 |
className | カスタムクラスが適用される |
テストツール
- Vitest (opens in new tab) - テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ
- jest-axe (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
Accordion.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 { Accordion, type AccordionItem } from './Accordion';
// Test accordion data
const defaultItems: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDisabled: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1' },
{ id: 'section2', header: 'Section 2', content: 'Content 2', disabled: true },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
const itemsWithDefaultExpanded: AccordionItem[] = [
{ id: 'section1', header: 'Section 1', content: 'Content 1', defaultExpanded: true },
{ id: 'section2', header: 'Section 2', content: 'Content 2' },
{ id: 'section3', header: 'Section 3', content: 'Content 3' },
];
// 7+ items (for region role test)
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
id: `section${i + 1}`,
header: `Section ${i + 1}`,
content: `Content ${i + 1}`,
}));
describe('Accordion', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: Keyboard Interaction', () => {
it('toggles panel with Enter', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{Enter}');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('toggles panel with Space', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
button.focus();
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.keyboard(' ');
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('moves focus to next header with ArrowDown', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button2 = screen.getByRole('button', { name: 'Section 2' });
expect(button2).toHaveFocus();
});
it('moves focus to previous header with ArrowUp', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button2 = screen.getByRole('button', { name: 'Section 2' });
button2.focus();
await user.keyboard('{ArrowUp}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('does not move focus when at last header with ArrowDown (no loop)', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{ArrowDown}');
// Focus does not move
expect(button3).toHaveFocus();
});
it('does not move focus when at first header with ArrowUp (no loop)', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowUp}');
// Focus does not move
expect(button1).toHaveFocus();
});
it('moves focus to first header with Home', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{Home}');
const button1 = screen.getByRole('button', { name: 'Section 1' });
expect(button1).toHaveFocus();
});
it('moves focus to last header with End', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{End}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('skips disabled headers when navigating', async () => {
const user = userEvent.setup();
render(<Accordion items={itemsWithDisabled} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
// Section 2 is skipped, moves to Section 3
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('disables arrow key navigation when enableArrowKeys=false', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} enableArrowKeys={false} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
// Focus does not move
expect(button1).toHaveFocus();
});
});
describe('APG: ARIA Attributes', () => {
it('header buttons have aria-expanded', () => {
render(<Accordion items={defaultItems} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded');
});
});
it('has aria-expanded="true" on open panel', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('has aria-expanded="false" on closed panel', () => {
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('header aria-controls matches panel id', () => {
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
const ariaControls = button.getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
expect(document.getElementById(ariaControls!)).toBeInTheDocument();
});
it('panels have role="region" when 6 or fewer', () => {
render(<Accordion items={defaultItems} />);
const regions = screen.getAllByRole('region');
expect(regions).toHaveLength(3);
});
it('panels do not have role="region" when 7 or more', () => {
render(<Accordion items={manyItems} />);
const regions = screen.queryAllByRole('region');
expect(regions).toHaveLength(0);
});
it('panel aria-labelledby matches header id', () => {
render(<Accordion items={defaultItems} />);
const button = screen.getByRole('button', { name: 'Section 1' });
const regions = screen.getAllByRole('region');
expect(regions[0]).toHaveAttribute('aria-labelledby', button.id);
});
it('disabled item has aria-disabled="true"', () => {
render(<Accordion items={itemsWithDisabled} />);
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-disabled', 'true');
});
});
describe('APG: Heading Structure', () => {
it('uses h3 element when headingLevel=3', () => {
render(<Accordion items={defaultItems} headingLevel={3} />);
const headings = document.querySelectorAll('h3');
expect(headings).toHaveLength(3);
});
it('uses h2 element when headingLevel=2', () => {
render(<Accordion items={defaultItems} headingLevel={2} />);
const headings = document.querySelectorAll('h2');
expect(headings).toHaveLength(3);
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations', async () => {
const { container } = render(<Accordion items={defaultItems} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('can specify initial expanded state with defaultExpanded', () => {
render(<Accordion items={itemsWithDefaultExpanded} />);
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('only one panel expanded when allowMultiple=false (default)', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
expect(button1).toHaveAttribute('aria-expanded', 'true');
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'false');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('multiple panels can be expanded when allowMultiple=true', async () => {
const user = userEvent.setup();
render(<Accordion items={defaultItems} allowMultiple />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
const button2 = screen.getByRole('button', { name: 'Section 2' });
await user.click(button1);
await user.click(button2);
expect(button1).toHaveAttribute('aria-expanded', 'true');
expect(button2).toHaveAttribute('aria-expanded', 'true');
});
it('calls onExpandedChange when expanded state changes', async () => {
const handleExpandedChange = vi.fn();
const user = userEvent.setup();
render(<Accordion items={defaultItems} onExpandedChange={handleExpandedChange} />);
await user.click(screen.getByRole('button', { name: 'Section 1' }));
expect(handleExpandedChange).toHaveBeenCalledWith(['section1']);
});
});
describe('Edge Cases', () => {
it('disabled item does not toggle on click', async () => {
const user = userEvent.setup();
render(<Accordion items={itemsWithDisabled} />);
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
await user.click(disabledButton);
expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
});
it('disabled item with defaultExpanded is not expanded', () => {
const items: AccordionItem[] = [
{
id: 'section1',
header: 'Section 1',
content: 'Content 1',
disabled: true,
defaultExpanded: true,
},
];
render(<Accordion items={items} />);
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
const { container } = render(<Accordion items={defaultItems} className="custom-accordion" />);
const accordionContainer = container.firstChild as HTMLElement;
expect(accordionContainer).toHaveClass('custom-accordion');
});
});
}); リソース
- WAI-ARIA APG: Accordion パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist