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
---
/**
* APG Accordion Pattern - Astro Implementation
*
* A vertically stacked set of interactive headings that each reveal a section of content.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
*/
export interface AccordionItem {
id: string;
header: string;
content: string;
disabled?: boolean;
defaultExpanded?: boolean;
}
export interface Props {
/** Array of accordion items */
items: AccordionItem[];
/** Allow multiple panels to be expanded simultaneously */
allowMultiple?: boolean;
/** Heading level for accessibility (2-6) */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** Enable arrow key navigation between headers */
enableArrowKeys?: boolean;
/** Additional CSS class */
class?: string;
}
const {
items,
allowMultiple = false,
headingLevel = 3,
enableArrowKeys = true,
class: className = "",
} = Astro.props;
// Generate unique ID for this instance
const instanceId = `accordion-${Math.random().toString(36).substring(2, 11)}`;
// Determine initially expanded items
const initialExpanded = items
.filter((item) => item.defaultExpanded && !item.disabled)
.map((item) => item.id);
// Use role="region" only for 6 or fewer panels (APG recommendation)
const useRegion = items.length <= 6;
// Dynamic heading tag
const HeadingTag = `h${headingLevel}`;
---
<apg-accordion
class={`apg-accordion ${className}`.trim()}
data-allow-multiple={allowMultiple}
data-enable-arrow-keys={enableArrowKeys}
data-expanded={JSON.stringify(initialExpanded)}
>
{
items.map((item) => {
const headerId = `${instanceId}-header-${item.id}`;
const panelId = `${instanceId}-panel-${item.id}`;
const isExpanded = initialExpanded.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 class={itemClass} data-item-id={item.id}>
<Fragment set:html={`<${HeadingTag} class="apg-accordion-header">`} />
<button
type="button"
id={headerId}
aria-expanded={isExpanded}
aria-controls={panelId}
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
class={triggerClass}
data-item-id={item.id}
>
<span class="apg-accordion-trigger-content">{item.header}</span>
<span class={iconClass} 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>
<Fragment set:html={`</${HeadingTag}>`} />
<div
role={useRegion ? "region" : undefined}
id={panelId}
aria-labelledby={useRegion ? headerId : undefined}
class={panelClass}
data-panel-id={item.id}
>
<div class="apg-accordion-panel-content">
<Fragment set:html={item.content} />
</div>
</div>
</div>
);
})
}
</apg-accordion>
<script>
class ApgAccordion extends HTMLElement {
private buttons: HTMLButtonElement[] = [];
private panels: HTMLElement[] = [];
private availableButtons: HTMLButtonElement[] = [];
private expandedIds: string[] = [];
private allowMultiple = false;
private enableArrowKeys = true;
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.buttons = Array.from(
this.querySelectorAll(".apg-accordion-trigger")
);
this.panels = Array.from(this.querySelectorAll(".apg-accordion-panel"));
if (this.buttons.length === 0 || this.panels.length === 0) {
console.warn("apg-accordion: buttons or panels not found");
return;
}
this.availableButtons = this.buttons.filter((btn) => !btn.disabled);
this.allowMultiple = this.dataset.allowMultiple === "true";
this.enableArrowKeys = this.dataset.enableArrowKeys !== "false";
this.expandedIds = JSON.parse(this.dataset.expanded || "[]");
// Attach event listeners
this.buttons.forEach((button) => {
button.addEventListener("click", this.handleClick);
});
if (this.enableArrowKeys) {
this.addEventListener("keydown", this.handleKeyDown);
}
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.buttons.forEach((button) => {
button.removeEventListener("click", this.handleClick);
});
this.removeEventListener("keydown", this.handleKeyDown);
// Clean up references
this.buttons = [];
this.panels = [];
this.availableButtons = [];
}
private togglePanel(itemId: string) {
const isCurrentlyExpanded = this.expandedIds.includes(itemId);
if (isCurrentlyExpanded) {
this.expandedIds = this.expandedIds.filter((id) => id !== itemId);
} else {
if (this.allowMultiple) {
this.expandedIds = [...this.expandedIds, itemId];
} else {
this.expandedIds = [itemId];
}
}
this.updateDOM();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("expandedchange", {
detail: { expandedIds: this.expandedIds },
bubbles: true,
})
);
}
private updateDOM() {
this.buttons.forEach((button) => {
const itemId = button.dataset.itemId!;
const isExpanded = this.expandedIds.includes(itemId);
const panel = this.panels.find((p) => p.dataset.panelId === itemId);
const item = button.closest(".apg-accordion-item");
const icon = button.querySelector(".apg-accordion-icon");
// Update button
button.setAttribute("aria-expanded", String(isExpanded));
button.classList.toggle("apg-accordion-trigger--expanded", isExpanded);
// Update icon
icon?.classList.toggle("apg-accordion-icon--expanded", isExpanded);
// Update panel visibility via CSS classes (not hidden attribute)
if (panel) {
panel.classList.toggle("apg-accordion-panel--expanded", isExpanded);
panel.classList.toggle("apg-accordion-panel--collapsed", !isExpanded);
}
// Update item
item?.classList.toggle("apg-accordion-item--expanded", isExpanded);
});
}
private handleClick = (e: Event) => {
const button = e.currentTarget as HTMLButtonElement;
if (button.disabled) return;
this.togglePanel(button.dataset.itemId!);
};
private handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("apg-accordion-trigger")) return;
const currentIndex = this.availableButtons.indexOf(
target as HTMLButtonElement
);
if (currentIndex === -1) return;
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (e.key) {
case "ArrowDown":
// Move to next, but don't wrap (APG compliant)
if (currentIndex < this.availableButtons.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 = this.availableButtons.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
e.preventDefault();
if (newIndex !== currentIndex) {
this.availableButtons[newIndex]?.focus();
}
}
};
}
// Register the custom element
if (!customElements.get("apg-accordion")) {
customElements.define("apg-accordion", ApgAccordion);
}
</script> Usage
---
import Accordion from './Accordion.astro';
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...',
},
];
---
<Accordion
items={items}
headingLevel={3}
allowMultiple={false}
/> 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 |
class | string | "" | Additional CSS class |
Events
| Event | Detail | Description |
|---|---|---|
expandedchange | { expandedIds: string[] } | Fired when the expanded panels change |
AccordionItem Interface
interface AccordionItem {
id: string;
header: string;
content: string;
disabled?: boolean;
defaultExpanded?: boolean;
} Implementation Notes
This Astro implementation uses Web Components (customElements.define)
for client-side interactivity. The accordion is rendered as static HTML on the server,
and the Web Component enhances it with keyboard navigation and state management.
- No JavaScript framework required on the client
- Works with SSG (Static Site Generation)
- Progressively enhanced - basic functionality works without JavaScript
- Minimal JavaScript bundle size