Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
🤖 AI Implementation GuideDemo
Single Expansion (Default)
Only one panel can be expanded at a time. Opening a new panel closes the previously open one.
Multiple Expansion
Multiple panels can be expanded simultaneously using the allowMultiple prop.
With Disabled Items
Individual accordion items can be disabled. Keyboard navigation automatically skips disabled items.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
heading | Header wrapper (h2-h6) | Contains the accordion trigger button |
button | Header trigger | Interactive element that toggles panel visibility |
region | Panel (optional) | Content area associated with header (omit for 6+ panels) |
WAI-ARIA Accordion Pattern (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-level | heading | 2 - 6 | Yes | headingLevel prop |
aria-controls | button | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | region (panel) | ID reference to header button | Yes (if region used) | Auto-generated |
WAI-ARIA States
aria-expanded
Indicates whether the accordion panel is expanded or collapsed.
| Target | button element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Click, Enter, Space |
| Reference | aria-expanded (opens in new tab) |
aria-disabled
Indicates whether the accordion header is disabled.
| Target | button element |
| Values | true | false |
| Required | No (only when disabled) |
| Reference | aria-disabled (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus to the next focusable element |
| Space / Enter | Toggle the expansion of the focused accordion header |
| Arrow Down | Move focus to the next accordion header (optional) |
| Arrow Up | Move focus to the previous accordion header (optional) |
| Home | Move focus to the first accordion header (optional) |
| End | Move focus to the last accordion header (optional) |
Arrow key navigation is optional but recommended. Focus does not wrap around at the end of the list.
Source Code
<script lang="ts">
/**
* APG Accordion Pattern - Svelte Implementation
*
* A vertically stacked set of interactive headings that each reveal a section of content.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
import { onMount } from 'svelte';
/**
* Accordion item configuration
*/
export interface AccordionItem {
/** Unique identifier for the item */
id: string;
/** Content displayed in the accordion header button */
header: string;
/** Content displayed in the collapsible panel (HTML string) */
content?: string;
/** 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
*/
interface AccordionProps {
items: AccordionItem[];
allowMultiple?: boolean;
headingLevel?: 2 | 3 | 4 | 5 | 6;
enableArrowKeys?: boolean;
onExpandedChange?: (expandedIds: string[]) => void;
className?: string;
}
let {
items = [],
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
onExpandedChange = () => {},
className = '',
}: AccordionProps = $props();
let expandedIds = $state<string[]>([]);
let instanceId = $state('');
let buttonRefs = $state<Record<string, HTMLButtonElement | undefined>>({});
onMount(() => {
instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
// Initialize with defaultExpanded items
if (Array.isArray(items)) {
expandedIds = items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
}
});
// Derived values
let safeItems = $derived(Array.isArray(items) ? items : []);
let availableItems = $derived(safeItems.filter((item) => !item.disabled));
let useRegion = $derived(safeItems.length <= 6);
function isExpanded(itemId: string): boolean {
return expandedIds.includes(itemId);
}
function handleToggle(itemId: string) {
const item = safeItems.find((i) => i.id === itemId);
if (item?.disabled) return;
const isCurrentlyExpanded = expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
expandedIds = expandedIds.filter((id) => id !== itemId);
} else {
if (allowMultiple) {
expandedIds = [...expandedIds, itemId];
} else {
expandedIds = [itemId];
}
}
onExpandedChange(expandedIds);
}
function handleKeyDown(event: 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':
if (currentIndex < availableItems.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case 'ArrowUp':
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[newItem.id]?.focus();
}
}
}
}
function getItemClass(item: AccordionItem): string {
let cls = 'apg-accordion-item';
if (isExpanded(item.id)) cls += ' apg-accordion-item--expanded';
if (item.disabled) cls += ' apg-accordion-item--disabled';
return cls;
}
function getTriggerClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-trigger apg-accordion-trigger--expanded'
: 'apg-accordion-trigger';
}
function getIconClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-icon apg-accordion-icon--expanded'
: 'apg-accordion-icon';
}
function getPanelClass(itemId: string): string {
return isExpanded(itemId)
? 'apg-accordion-panel apg-accordion-panel--expanded'
: 'apg-accordion-panel apg-accordion-panel--collapsed';
}
</script>
{#if safeItems.length > 0}
<div class="apg-accordion {className}">
{#each safeItems as item (item.id)}
{@const headerId = `${instanceId}-header-${item.id}`}
{@const panelId = `${instanceId}-panel-${item.id}`}
<div class={getItemClass(item)}>
{#if headingLevel === 2}
<h2 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h2>
{:else if headingLevel === 3}
<h3 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h3>
{:else if headingLevel === 4}
<h4 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h4>
{:else if headingLevel === 5}
<h5 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h5>
{:else}
<h6 class="apg-accordion-header">
<button
bind:this={buttonRefs[item.id]}
type="button"
id={headerId}
aria-expanded={isExpanded(item.id)}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={getTriggerClass(item.id)}
onclick={() => handleToggle(item.id)}
onkeydown={(e) => handleKeyDown(e, item.id)}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={getIconClass(item.id)} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</h6>
{/if}
<div
role={useRegion ? 'region' : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
class={getPanelClass(item.id)}
>
<div class="apg-accordion-panel-content">
{#if item.content}
{@html item.content}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if} Usage
<script>
import Accordion from './Accordion.svelte';
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 handleExpandedChange(ids) {
console.log('Expanded:', ids);
}
</script>
<Accordion
{items}
headingLevel={3}
allowMultiple={false}
onExpandedChange={handleExpandedChange}
/> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | AccordionItem[] | required | Array of accordion items |
allowMultiple | boolean | false | Allow multiple panels to be expanded |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level for accessibility |
enableArrowKeys | boolean | true | Enable arrow key navigation |
onExpandedChange | (ids: string[]) => void | - | Callback when expansion changes |
AccordionItem Interface
interface AccordionItem {
id: string;
header: string;
content?: string;
disabled?: boolean;
defaultExpanded?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
Enter key | Expands/collapses the focused panel |
Space key | Expands/collapses the focused panel |
ArrowDown | Moves focus to next header |
ArrowUp | Moves focus to previous header |
Home | Moves focus to first header |
End | Moves focus to last header |
No loop | Focus stops at edges (no wrapping) |
Disabled skip | Skips disabled headers during navigation |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
aria-expanded | Header button reflects expand/collapse state |
aria-controls | Header references its panel via aria-controls |
aria-labelledby | Panel references its header via aria-labelledby |
role="region" | Panel has region role (6 or fewer panels) |
No region (7+) | Panel omits region role when 7+ panels |
aria-disabled | Disabled items have aria-disabled="true" |
High Priority: Heading Structure
| Test | Description |
|---|---|
headingLevel prop | Uses correct heading element (h2, h3, etc.) |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Low Priority: Props & Behavior
| Test | Description |
|---|---|
allowMultiple | Controls single vs. multiple expansion |
defaultExpanded | Sets initial expansion state |
className | Custom classes are applied |
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.
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Accordion from './Accordion.svelte';
import type { AccordionItem } from './Accordion.svelte';
// テスト用のアコーディオンデータ
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個以上のアイテム(region role テスト用)
const manyItems: AccordionItem[] = Array.from({ length: 7 }, (_, i) => ({
id: `section${i + 1}`,
header: `Section ${i + 1}`,
content: `Content ${i + 1}`,
}));
describe('Accordion (Svelte)', () => {
// 🔴 High Priority: APG 準拠の核心
describe('APG: キーボード操作', () => {
it('Enter でパネルを開閉する', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('Space でパネルを開閉する', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('ArrowDown で次のヘッダーにフォーカス移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('ArrowUp で前のヘッダーにフォーカス移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('ArrowDown で最後のヘッダーにいる場合、移動しない(ループなし)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button3 = screen.getByRole('button', { name: 'Section 3' });
button3.focus();
await user.keyboard('{ArrowDown}');
expect(button3).toHaveFocus();
});
it('ArrowUp で最初のヘッダーにいる場合、移動しない(ループなし)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowUp}');
expect(button1).toHaveFocus();
});
it('Home で最初のヘッダーに移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('End で最後のヘッダーに移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('disabled ヘッダーをスキップして移動', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: itemsWithDisabled } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
const button3 = screen.getByRole('button', { name: 'Section 3' });
expect(button3).toHaveFocus();
});
it('enableArrowKeys=false で矢印キーナビゲーション無効', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, enableArrowKeys: false } });
const button1 = screen.getByRole('button', { name: 'Section 1' });
button1.focus();
await user.keyboard('{ArrowDown}');
expect(button1).toHaveFocus();
});
});
describe('APG: ARIA 属性', () => {
it('ヘッダーボタンが aria-expanded を持つ', () => {
render(Accordion, { props: { items: defaultItems } });
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('aria-expanded');
});
});
it('開いたパネルで aria-expanded="true"', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('閉じたパネルで aria-expanded="false"', () => {
render(Accordion, { props: { items: defaultItems } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('ヘッダーの aria-controls がパネル id と一致', () => {
render(Accordion, { props: { 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('6個以下のパネルで role="region" を持つ', () => {
render(Accordion, { props: { items: defaultItems } });
const regions = screen.getAllByRole('region');
expect(regions).toHaveLength(3);
});
it('7個以上のパネルで role="region" を持たない', () => {
render(Accordion, { props: { items: manyItems } });
const regions = screen.queryAllByRole('region');
expect(regions).toHaveLength(0);
});
it('パネルの aria-labelledby がヘッダー id と一致', () => {
render(Accordion, { props: { 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 項目が aria-disabled="true" を持つ', () => {
render(Accordion, { props: { items: itemsWithDisabled } });
const disabledButton = screen.getByRole('button', { name: 'Section 2' });
expect(disabledButton).toHaveAttribute('aria-disabled', 'true');
});
});
describe('APG: 見出し構造', () => {
it('headingLevel=3 で h3 要素を使用', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 3 } });
const headings = document.querySelectorAll('h3');
expect(headings).toHaveLength(3);
});
it('headingLevel=2 で h2 要素を使用', () => {
render(Accordion, { props: { items: defaultItems, headingLevel: 2 } });
const headings = document.querySelectorAll('h2');
expect(headings).toHaveLength(3);
});
});
// 🟡 Medium Priority: アクセシビリティ検証
describe('アクセシビリティ', () => {
it('axe による WCAG 2.1 AA 違反がない', async () => {
const { container } = render(Accordion, { props: { items: defaultItems } });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe('Props', () => {
it('defaultExpanded で初期展開状態を指定できる', () => {
render(Accordion, { props: { items: itemsWithDefaultExpanded } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('allowMultiple=false で1つのみ展開(デフォルト)', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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('allowMultiple=true で複数展開可能', async () => {
const user = userEvent.setup();
render(Accordion, { props: { items: defaultItems, allowMultiple: true } });
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('onExpandedChange が展開状態変化時に呼び出される', async () => {
const handleExpandedChange = vi.fn();
const user = userEvent.setup();
render(Accordion, {
props: { items: defaultItems, onExpandedChange: handleExpandedChange },
});
await user.click(screen.getByRole('button', { name: 'Section 1' }));
expect(handleExpandedChange).toHaveBeenCalledWith(['section1']);
});
});
describe('異常系', () => {
it('disabled 項目はクリックで開閉しない', async () => {
const user = userEvent.setup();
render(Accordion, { props: { 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 かつ defaultExpanded の項目は展開されない', () => {
const items: AccordionItem[] = [
{
id: 'section1',
header: 'Section 1',
content: 'Content 1',
disabled: true,
defaultExpanded: true,
},
];
render(Accordion, { props: { items } });
const button = screen.getByRole('button', { name: 'Section 1' });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
});
}); Resources
- WAI-ARIA APG: Accordion Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist