Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Demo
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
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 HeadingTag = `h${headingLevel}` as "h2" | "h3" | "h4" | "h5" | "h6";
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; Usage
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
| 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 |
className | string | "" | Additional CSS class |
AccordionItem
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for the item |
header | ReactNode | Yes | Content for the accordion header |
content | ReactNode | Yes | Content for the accordion panel |
disabled | boolean | No | Disable the accordion item |
defaultExpanded | boolean | No | Initially expanded state |
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/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";
// ใในใ็จใฎใขใณใผใใฃใชใณใใผใฟ
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", () => {
// ๐ด High Priority: APG ๆบๆ ใฎๆ ธๅฟ
describe("APG: ใญใผใใผใๆไฝ", () => {
it("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("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("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("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("ArrowDown ใงๆๅพใฎใใใใผใซใใๅ ดๅใ็งปๅใใชใ๏ผใซใผใใชใ๏ผ", async () => {
const user = userEvent.setup();
render(<Accordion 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 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 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 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 items={itemsWithDisabled} />);
const button1 = screen.getByRole("button", { name: "Section 1" });
button1.focus();
await user.keyboard("{ArrowDown}");
// Section 2 ใฏในใญใใใใใSection 3 ใซ็งปๅ
const button3 = screen.getByRole("button", { name: "Section 3" });
expect(button3).toHaveFocus();
});
it("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}");
// ใใฉใผใซในใฏ็งปๅใใชใ
expect(button1).toHaveFocus();
});
});
describe("APG: ARIA ๅฑๆง", () => {
it("ใใใใผใใฟใณใ aria-expanded ใๆใค", () => {
render(<Accordion 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 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 items={defaultItems} />);
const button = screen.getByRole("button", { name: "Section 1" });
expect(button).toHaveAttribute("aria-expanded", "false");
});
it("ใใใใผใฎ aria-controls ใใใใซ 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('6ๅไปฅไธใฎใใใซใง role="region" ใๆใค', () => {
render(<Accordion items={defaultItems} />);
const regions = screen.getAllByRole("region");
expect(regions).toHaveLength(3);
});
it('7ๅไปฅไธใฎใใใซใง role="region" ใๆใใชใ', () => {
render(<Accordion items={manyItems} />);
const regions = screen.queryAllByRole("region");
expect(regions).toHaveLength(0);
});
it("ใใใซใฎ aria-labelledby ใใใใใผ 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 ้
็ฎใ aria-disabled="true" ใๆใค', () => {
render(<Accordion items={itemsWithDisabled} />);
const disabledButton = screen.getByRole("button", { name: "Section 2" });
expect(disabledButton).toHaveAttribute("aria-disabled", "true");
});
});
describe("APG: ่ฆๅบใๆง้ ", () => {
it("headingLevel=3 ใง h3 ่ฆ็ด ใไฝฟ็จ", () => {
render(<Accordion items={defaultItems} headingLevel={3} />);
const headings = document.querySelectorAll("h3");
expect(headings).toHaveLength(3);
});
it("headingLevel=2 ใง h2 ่ฆ็ด ใไฝฟ็จ", () => {
render(<Accordion 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 items={defaultItems} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe("Props", () => {
it("defaultExpanded ใงๅๆๅฑ้็ถๆ
ใๆๅฎใงใใ", () => {
render(<Accordion 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 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 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("onExpandedChange ใๅฑ้็ถๆ
ๅคๅๆใซๅผใณๅบใใใ", 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("็ฐๅธธ็ณป", () => {
it("disabled ้
็ฎใฏใฏใชใใฏใง้้ใใชใ", 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 ใใค defaultExpanded ใฎ้
็ฎใฏๅฑ้ใใใชใ", () => {
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: ๆกๅผตๆง
describe("HTML ๅฑๆง็ถๆฟ", () => {
it("className ใใณใณใใใซ้ฉ็จใใใ", () => {
const { container } = render(
<Accordion items={defaultItems} className="custom-accordion" />
);
const accordionContainer = container.firstChild as HTMLElement;
expect(accordionContainer).toHaveClass("custom-accordion");
});
});
});