APG Patterns
日本語
日本語

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.

An accordion is a vertically stacked set of interactive headings that each reveal a section of content. They are commonly used to reduce the need to scroll when presenting multiple sections of content on a single page.

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.

Multiple Expansion

Multiple panels can be expanded simultaneously using the allowMultiple prop.

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.

With Disabled Items

Individual accordion items can be disabled. Keyboard navigation automatically skips disabled items.

This section can be expanded and collapsed normally.

This content is not accessible because the section is disabled.

This section can also be expanded. The disabled section is removed from the Tab order, so Tab moves directly between the enabled sections.

Open demo only →

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 Properties

aria-level

headingLevel prop

Values
2 - 6
Required
Yes

aria-controls

Auto-generated

Values
ID reference to associated panel
Required
Yes

aria-labelledby

Auto-generated

Values
ID reference to header button
Required
Yes (if region used)

WAI-ARIA States

aria-expanded

Target Element
button element
Values
true | false
Required
Yes
Change Trigger
Click, Enter, Space

aria-disabled

Target Element
button element
Values
true | false
Required
No
Change Trigger
Only when disabled

Keyboard Support

Key Action
Tab Move focus to the next focusable element
Shift + Tab Move focus to the previous focusable element
Space / Enter Toggle the expansion of the focused accordion header
  • Header navigation uses the standard Tab order; arrow / Home / End key navigation is no longer part of the APG Accordion keyboard interaction.

Focus Management

Event Behavior
Header buttons Focusable via their button elements
Tab order Headers participate in the page Tab sequence; navigate between headers with Tab / Shift+Tab

Implementation Notes

┌─────────────────────────────────────┐
│ [▼] Section 1                       │  ← button (aria-expanded="true")
├─────────────────────────────────────┤
│ Panel 1 content...                  │  ← region (aria-labelledby)
├─────────────────────────────────────┤
│ [▶] Section 2                       │  ← button (aria-expanded="false")
├─────────────────────────────────────┤
│ [▶] Section 3                       │  ← button (aria-expanded="false")
└─────────────────────────────────────┘

ID Relationships:
- Button: id="header-1", aria-controls="panel-1"
- Panel: id="panel-1", aria-labelledby="header-1"

Region Role Rule:
- ≤6 panels: use role="region" on panels
- >6 panels: omit role="region" (too many landmarks)

Accordion component structure with ID relationships

References

Source Code

Accordion.astro
---
/**
 * 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;
  /** Additional CSS class */
  class?: string;
}

const { items, allowMultiple = false, headingLevel = 3, 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-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 expandedIds: string[] = [];
    private allowMultiple = false;
    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.allowMultiple = this.dataset.allowMultiple === 'true';
      this.expandedIds = JSON.parse(this.dataset.expanded || '[]');

      // Attach event listeners
      this.buttons.forEach((button) => {
        button.addEventListener('click', this.handleClick);
      });
    }

    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);
      });
      // Clean up references
      this.buttons = [];
      this.panels = [];
    }

    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!);
    };
  }

  // Register the custom element
  if (!customElements.get('apg-accordion')) {
    customElements.define('apg-accordion', ApgAccordion);
  }
</script>

Usage

Example
---
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

PropTypeDefaultDescription
itemsAccordionItem[]requiredArray of accordion items
allowMultiplebooleanfalseAllow multiple panels to be expanded
headingLevel2 | 3 | 4 | 5 | 63Heading level for accessibility
classstring""Additional CSS class

AccordionItem Props

PropTypeDefaultDescription
idstringrequiredUnique item identifier
headerstringrequiredHeader text for the accordion trigger
contentstringrequiredContent of the accordion panel
disabledbooleanfalseWhether the item is disabled
defaultExpandedbooleanfalseWhether the item is initially expanded

Custom Events

EventDetailDescription
expandedchange{ expandedIds: string[] }Fired when the expanded panels change

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Accordion component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (aria-expanded, aria-controls, aria-labelledby)
  • Keyboard interaction (Enter, Space)
  • Expand/collapse behavior
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • Click interactions
  • Enter / Space toggle
  • ARIA structure validation in live browser
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG Keyboard Interaction (Unit + E2E)

TestDescription
Enter keyExpands/collapses the focused panel
Space keyExpands/collapses the focused panel

High Priority: APG ARIA Attributes (Unit + E2E)

TestDescription
aria-expandedHeader button reflects expand/collapse state
aria-controlsHeader references its panel via aria-controls
aria-labelledbyPanel 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-disabledDisabled items have aria-disabled="true"

High Priority: Click Interaction (Unit + E2E)

TestDescription
Click expandsClicking header expands panel
Click collapsesClicking expanded header collapses panel
Single expansionOpening panel closes other panels (default)
Multiple expansionMultiple panels can be open with allowMultiple

High Priority: Heading Structure (Unit + E2E)

TestDescription
headingLevel propUses correct heading element (h2, h3, etc.)

Medium Priority: Disabled State (Unit + E2E)

TestDescription
Disabled no clickClicking disabled header does not expand
Disabled no keyboardEnter/Space does not activate disabled header

Medium Priority: Accessibility (Unit + E2E)

TestDescription
axe violationsNo WCAG 2.1 AA violations (via jest-axe/axe-core)

Low Priority: Cross-framework Consistency (E2E)

TestDescription
All frameworks renderReact, Vue, Svelte, Astro all render accordions
Consistent ARIAAll frameworks have consistent ARIA structure

Example Test Code

The following is the actual E2E test file (e2e/accordion.spec.ts).

e2e/accordion.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Accordion Pattern
 *
 * A vertically stacked set of interactive headings that each reveal
 * a section of content.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getAccordion = (page: import('@playwright/test').Page) => {
  return page.locator('.apg-accordion');
};

const getAccordionHeaders = (page: import('@playwright/test').Page) => {
  return page.locator('.apg-accordion-trigger');
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Accordion (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/accordion/${framework}/demo/`);
      await getAccordion(page).first().waitFor();

      // Wait for hydration to complete - aria-controls should have a proper ID (not starting with hyphen)
      const firstHeader = getAccordionHeaders(page).first();
      await expect
        .poll(async () => {
          const id = await firstHeader.getAttribute('aria-controls');
          // ID should be non-empty and not start with hyphen (Svelte pre-hydration)
          return id && id.length > 1 && !id.startsWith('-');
        })
        .toBe(true);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('accordion headers have aria-expanded attribute', async ({ page }) => {
        const headers = getAccordionHeaders(page);
        const firstHeader = headers.first();

        // Should have aria-expanded (either true or false)
        const expanded = await firstHeader.getAttribute('aria-expanded');
        expect(['true', 'false']).toContain(expanded);
      });

      test('accordion headers have aria-controls referencing panel', async ({ page }) => {
        const headers = getAccordionHeaders(page);
        const firstHeader = headers.first();

        // Wait for aria-controls to be set
        await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);

        const controlsId = await firstHeader.getAttribute('aria-controls');
        expect(controlsId).toBeTruthy();

        // Panel with that ID should exist
        const panel = page.locator(`[id="${controlsId}"]`);
        await expect(panel).toBeAttached();
      });

      test('panels have role="region" when 6 or fewer items', async ({ page }) => {
        const accordion = getAccordion(page).first();
        const headers = accordion.locator('.apg-accordion-trigger');
        const count = await headers.count();

        if (count <= 6) {
          const panels = accordion.locator('.apg-accordion-panel');
          const firstPanel = panels.first();
          await expect(firstPanel).toHaveRole('region');
        }
      });

      test('panels have aria-labelledby referencing header', async ({ page }) => {
        const accordion = getAccordion(page).first();
        const headers = accordion.locator('.apg-accordion-trigger');
        const count = await headers.count();

        // Only check aria-labelledby when role="region" is present (≤6 items)
        if (count <= 6) {
          const firstHeader = headers.first();

          // Wait for aria-controls to be set
          await expect(firstHeader).toHaveAttribute('aria-controls', /.+/);

          const headerId = await firstHeader.getAttribute('id');
          const controlsId = await firstHeader.getAttribute('aria-controls');
          const panel = page.locator(`[id="${controlsId}"]`);

          await expect(panel).toHaveAttribute('aria-labelledby', headerId!);
        }
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Click Interaction
    // ------------------------------------------
    test.describe('APG: Click Interaction', () => {
      test('clicking header toggles panel expansion', async ({ page }) => {
        const accordion = getAccordion(page).first();
        // Use second header which is not expanded by default
        const header = accordion.locator('.apg-accordion-trigger').nth(1);

        // Wait for component to be interactive (hydration complete)
        await expect(header).toHaveAttribute('aria-expanded', 'false');

        await header.click();

        await expect(header).toHaveAttribute('aria-expanded', 'true');
      });

      test('single expansion mode: opening one panel closes others', async ({ page }) => {
        // First accordion uses single expansion mode
        const accordion = getAccordion(page).first();
        const headers = accordion.locator('.apg-accordion-trigger');

        // Wait for hydration - first header should be expanded by default
        const firstHeader = headers.first();
        await expect(firstHeader).toHaveAttribute('aria-expanded', 'true');

        // Click second header
        const secondHeader = headers.nth(1);
        await secondHeader.click();

        // Second should be open, first should be closed
        await expect(secondHeader).toHaveAttribute('aria-expanded', 'true');
        await expect(firstHeader).toHaveAttribute('aria-expanded', 'false');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('Enter/Space toggles panel expansion', async ({ page }) => {
        const accordion = getAccordion(page).first();
        // Use second header which is collapsed by default
        const header = accordion.locator('.apg-accordion-trigger').nth(1);

        // Wait for component to be ready
        await expect(header).toHaveAttribute('aria-expanded', 'false');

        // Click to set focus (this also opens the panel)
        await header.click();
        await expect(header).toBeFocused();
        await expect(header).toHaveAttribute('aria-expanded', 'true');

        // Press Enter to toggle (should collapse)
        await expect(header).toBeFocused();
        await header.press('Enter');
        await expect(header).toHaveAttribute('aria-expanded', 'false');

        // Press Space to toggle (should expand)
        await expect(header).toBeFocused();
        await header.press('Space');
        await expect(header).toHaveAttribute('aria-expanded', 'true');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled header cannot be clicked to expand', async ({ page }) => {
        // Third accordion has disabled items
        const accordions = getAccordion(page);
        const count = await accordions.count();

        // Find accordion with disabled item
        for (let i = 0; i < count; i++) {
          const accordion = accordions.nth(i);
          const disabledHeader = accordion.locator('.apg-accordion-trigger[disabled]');

          if ((await disabledHeader.count()) > 0) {
            const header = disabledHeader.first();
            const initialExpanded = await header.getAttribute('aria-expanded');

            await header.click({ force: true });

            // State should not change
            await expect(header).toHaveAttribute('aria-expanded', initialExpanded!);
            break;
          }
        }
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const accordion = getAccordion(page);
        await accordion.first().waitFor();

        const results = await new AxeBuilder({ page })
          .include('.apg-accordion')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Accordion - Cross-framework Consistency', () => {
  test('all frameworks have accordions', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/accordion/${framework}/demo/`);
      await getAccordion(page).first().waitFor();

      const accordions = getAccordion(page);
      const count = await accordions.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks support click to expand', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/accordion/${framework}/demo/`);
      await getAccordion(page).first().waitFor();

      const accordion = getAccordion(page).first();
      // Use second header which is not expanded by default
      const header = accordion.locator('.apg-accordion-trigger').nth(1);

      // Wait for the component to be interactive (not expanded by default)
      await expect(header).toHaveAttribute('aria-expanded', 'false');

      // Click to toggle
      await header.click();

      // State should change to expanded
      await expect(header).toHaveAttribute('aria-expanded', 'true');
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/accordion/${framework}/demo/`);
      await getAccordion(page).first().waitFor();

      const accordion = getAccordion(page).first();
      const header = accordion.locator('.apg-accordion-trigger').first();

      // Wait for hydration - aria-controls should be set
      await expect(header).toHaveAttribute('aria-controls', /.+/);

      // Check aria-expanded exists
      const expanded = await header.getAttribute('aria-expanded');
      expect(['true', 'false']).toContain(expanded);

      // Check aria-controls exists and references valid panel
      const controlsId = await header.getAttribute('aria-controls');
      expect(controlsId).toBeTruthy();

      const panel = page.locator(`[id="${controlsId}"]`);
      await expect(panel).toBeAttached();
    }
  });
});

Running Tests

# Run unit tests for Accordion
npm run test -- accordion

# Run E2E tests for Accordion (all frameworks)
npm run test:e2e:pattern --pattern=accordion

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=accordion

npm run test:e2e:vue:pattern --pattern=accordion

npm run test:e2e:svelte:pattern --pattern=accordion

npm run test:e2e:astro:pattern --pattern=accordion

Testing Tools

See the Testing Strategy guide for details.

Resources