Accessibility Developer Kit · Guide 08 / 20

Accessible Accordion

Native <details> (preferred for simple cases) and the ARIA disclosure accordion (preferred for complex ones). Also covers multi-open vs single-open, and the common div anti-pattern — in Vanilla and React.

  • WCAG: 1.3.1 · 2.1.1 · 4.1.2
  • Level: Beginner → Intermediate
  • Icons: lucide-react (ChevronDown)
1

What we are building

Expandable sections that work for keyboard and screen reader users.

What we are building

An accordion is a stack of show/hide items. For the simplest FAQ layouts, native <details> is the right answer — zero JavaScript, perfect keyboard, perfect SR support. For richer accordions (controlled state, animations, exclusive mode) use the ARIA disclosure pattern: a real<button aria-expanded> paired with a hidden panel.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Native <details> / <summary>

Preferred (simple)
Live example
What is accessibility?

Making your site usable by everyone — keyboard, screen reader, voice, touch.

Why does it matter?

15% of the world has a disability. Plus WCAG compliance is legally required in many regions.

Where do I start?

Install axe DevTools and run it on your main pages. Fix every critical and serious issue first.

No JavaScript. Browser handles Space, Enter, open/close state, and SR semantics.

HTML · Accessible
<!-- Native details/summary. Zero JS. Browser does everything. -->
<details>
  <summary>What is accessibility?</summary>
  <p>Making your site usable by everyone.</p>
</details>

<details>
  <summary>Why does it matter?</summary>
  <p>15% of the world has a disability.</p>
</details>

<details open>
  <summary>Where do I start?</summary>
  <p>Install axe DevTools and fix critical issues first.</p>
</details>

Zero JS. Browser handles Space, Enter, open/close state, and SR semantics. Perfect for FAQs.

Variant B

Div accordion (anti-pattern)

Critical fix
Live example

Tab-only test: the div-version is unreachable. The fix is focusable, Enter/Space works, SR announces state.

HTML · Accessible
<!-- Real button + aria-expanded + aria-controls. Panel toggles via hidden. -->
<h3 class="acc-heading">
  <button
    type="button"
    class="acc-trigger"
    aria-expanded="false"
    aria-controls="shipping-panel"
  >
    Shipping information
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>
</h3>
<div id="shipping-panel" hidden class="acc-panel">
  Ships in 2-3 business days.
</div>

Divs are not focusable, keyboard-activatable, or SR-visible. Replace with real <button> + aria-expanded + aria-controls.

Variant C

ARIA accordion (multi-open)

Preferred (complex)
Live example

Yes, within 30 days of purchase.

All items can be open at the same time. Each has its own aria-expanded.

HTML · Accessible
<!-- Each item has its own button + aria-expanded + aria-controls -->
<div class="acc">
  <h3>
    <button type="button" aria-expanded="true"  aria-controls="p-a">Item A</button>
  </h3>
  <div id="p-a">Panel A content</div>

  <h3>
    <button type="button" aria-expanded="false" aria-controls="p-b">Item B</button>
  </h3>
  <div id="p-b" hidden>Panel B content</div>

  <h3>
    <button type="button" aria-expanded="false" aria-controls="p-c">Item C</button>
  </h3>
  <div id="p-c" hidden>Panel C content</div>
</div>

Each item has its own trigger with aria-expanded + aria-controls. All items can be open at the same time.

Variant D

Exclusive (single-open)

FAQ style
Live example

Yes, cancel from your dashboard, effective at end of billing period.

Opening a new item closes the others. One at a time only.

JSX · Accessible
<!-- Exclusive accordion in JS -->
const [openId, setOpenId] = useState(null);

onClick(id => setOpenId(cur => cur === id ? null : id));

/* OR, with modern browsers: native exclusive accordion */
<details name="faq">
  <summary>Can I cancel anytime?</summary>
  <p>...</p>
</details>
<details name="faq">
  <summary>Do you have a free tier?</summary>
  <p>...</p>
</details>
/* Items sharing the same name attribute are mutually exclusive. */

Only one panel open at a time. Also achievable in modern browsers with <details name='group'>.

3

Flagship implementation

One AccessibleAccordion that handles multi-open and exclusive modes.

Flagship implementation

AccessibleAccordion.jsx
import React, { useState } from "react";
import { ChevronDown } from "lucide-react";
import "./AccessibleAccordion.css";

/**
 * AccessibleAccordion
 *  - items:     [{ id, title, content }]
 *  - exclusive: true to allow only one open at a time
 *  - defaultOpen: id to open initially (optional)
 */
export default function AccessibleAccordion({
  items,
  exclusive = false,
  defaultOpen,
}) {
  const initial = exclusive
    ? (defaultOpen ?? null)
    : new Set(defaultOpen ? [defaultOpen] : []);
  const [state, setState] = useState(initial);

  function isOpen(id) {
    return exclusive ? state === id : state.has(id);
  }

  function toggle(id) {
    if (exclusive) {
      setState((cur) => (cur === id ? null : id));
    } else {
      setState((cur) => {
        const next = new Set(cur);
        next.has(id) ? next.delete(id) : next.add(id);
        return next;
      });
    }
  }

  return (
    <div className="acc">
      {items.map((it) => {
        const open = isOpen(it.id);
        return (
          <div key={it.id} className="acc__item">
            <h3 className="acc__heading">
              <button
                type="button"
                className="acc__trigger"
                aria-expanded={open}
                aria-controls={`acc-${it.id}`}
                onClick={() => toggle(it.id)}
              >
                <span>{it.title}</span>
                <ChevronDown
                  aria-hidden="true"
                  size={16}
                  className={`acc__chev ${open ? "is-open" : ""}`}
                />
              </button>
            </h3>
            <div
              id={`acc-${it.id}`}
              hidden={!open}
              className="acc__panel"
            >
              {it.content}
            </div>
          </div>
        );
      })}
    </div>
  );
}
Usage · FAQPage.jsx
import AccessibleAccordion from "./AccessibleAccordion";

const FAQ = [
  { id: "ship",   title: "Shipping information",  content: "Ships in 2-3 days..." },
  { id: "return", title: "Return policy",         content: "30-day returns..." },
  { id: "sizes",  title: "Size guide",            content: "Measurements..." },
];

export default function FAQPage() {
  return (
    <>
      {/* Multi-open (default) */}
      <AccessibleAccordion items={FAQ} defaultOpen="ship" />

      {/* Exclusive (single-open) */}
      <AccessibleAccordion
        items={FAQ}
        exclusive
        defaultOpen="ship"
      />
    </>
  );
}
4

Keyboard behavior

Both patterns respond to Space and Enter.

Keyboard behavior

KeyExpected result
TabMoves to the next trigger button / summary
Enter / SpaceToggles the panel
Shift + TabMoves to the previous trigger
EscNo effect (accordions are not modal)
5

Screen reader expectations

Both patterns announce state via aria-expanded.

Screen reader expectations

  • Native collapsed: “What is accessibility?, disclosure triangle, collapsed”
  • Native expanded: “What is accessibility?, disclosure triangle, expanded”
  • ARIA collapsed: “Shipping information, collapsed, button”
  • ARIA expanded: “Shipping information, expanded, button”
  • Panel entry: SR reads the panel content as normal flow
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)Trigger is a semantic button inside a heading for structure.
2.1.1 Keyboard (A)Must open/close with Enter and Space.
4.1.2 Name, Role, Value (A)Must expose aria-expanded and link to its panel.
2.5.5 Target Size (AA, 2.2)Trigger must be at least 24×24 (prefer 44×44).
1.4.11 Non-text Contrast (AA)Open/closed indicator (chevron) must have 3:1 contrast.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: render → axe → click trigger → assert aria-expanded flips → in exclusive mode, assert siblings close.

Jest + Testing Library + jest-axe

AccessibleAccordion.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleAccordion from "./AccessibleAccordion";

expect.extend(toHaveNoViolations);

const items = [
  { id: "a", title: "A", content: "A content" },
  { id: "b", title: "B", content: "B content" },
];

test("toggles aria-expanded on click", async () => {
  const user = userEvent.setup();
  render(<AccessibleAccordion items={items} />);
  const btn = screen.getByRole("button", { name: "A" });
  expect(btn).toHaveAttribute("aria-expanded", "false");
  await user.click(btn);
  expect(btn).toHaveAttribute("aria-expanded", "true");
});

test("exclusive mode closes other items when opening one", async () => {
  const user = userEvent.setup();
  render(<AccessibleAccordion items={items} exclusive defaultOpen="a" />);
  const a = screen.getByRole("button", { name: "A" });
  const b = screen.getByRole("button", { name: "B" });
  expect(a).toHaveAttribute("aria-expanded", "true");
  await user.click(b);
  expect(a).toHaveAttribute("aria-expanded", "false");
  expect(b).toHaveAttribute("aria-expanded", "true");
});

test("has no axe violations", async () => {
  const { container } = render(<AccessibleAccordion items={items} />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleAccordion.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import AccessibleAccordion from "./AccessibleAccordion";

test("renders each title as a button inside a heading", () => {
  render(
    <AccessibleAccordion items={[{ id: "x", title: "X", content: "X" }]} />
  );
  expect(screen.getByRole("button", { name: "X" })).toBeInTheDocument();
});

Playwright + @axe-core/playwright

accordion.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("accordion page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-accordion");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("native details toggles with Space", async ({ page }) => {
  await page.goto("/en/accessible-accordion");
  const summary = page.getByText("What is accessibility?");
  await summary.focus();
  await page.keyboard.press("Space");
  await expect(summary.locator("..")).toHaveAttribute("open", "");
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Trigger is <summary> or a real <button> (not div)
  • Trigger lives inside a heading (<h2>/<h3>) for structure
  • ARIA accordion uses aria-expanded + aria-controls
  • Panel toggles via hidden, not just CSS display:none
  • Space and Enter both toggle the panel
  • Visible focus ring on the trigger
  • Open/closed icon has 3:1 contrast against background
  • Trigger meets 44×44 target size
  • No two triggers on the page share the same accessible name
  • Zero critical / serious axe violations
9

Common mistakes

The accordion bugs we see on nearly every audit.

Common mistakes

  • Div trigger with onClick. No keyboard, no role, no aria-expanded.
  • Trigger NOT inside a heading. SR users lose structural landmarks.
  • Panel toggled with CSS alone. SR still reads hidden content. Use hidden attribute.
  • Chevron rotation without text change. Chevron must be aria-hidden; state carried by aria-expanded.
  • Animating with max-height but not using hidden. Content stays in the a11y tree even when visually collapsed.
  • Over-reaching for role="region". Only use if the panel is lengthy / landmark-worthy.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo