Skip to content
Contact me

All building blocks

Sandbox

The accordion

An accordion made of clickable div elements has no button role and no state, and Tab skips it. Here, each question is a real button inside an h3 heading: the screen reader finds it in the list of headings, and aria-expanded says whether the answer is open.

Built with
h3, button and aria-expanded
Browsers
Chrome 105+, Firefox 121+, Safari 15.4+
JavaScript
13 lines, to open and close

Demo

Turn JavaScript off: the 4 answers stay visible under their question.

In My account, open Security, then Change password. An email confirms the change.

In My account, open Profile and enter the new address. It becomes active once you click the link you receive.

In My account, open Orders: each order has its invoice as a PDF.

Write to customer service from the Contact page. The account and its data are deleted within 30 days.

Anatomy

  1. h3 The heading that holds the question and puts it in the page's list of headings.
  2. button The question. Its aria-expanded attribute gives the state, collapsed or expanded.
  3. div The answer, hidden with hidden="until-found" while the question is closed: the browser's find in page still finds its text and opens it.
  4. aria-controls Ties the button to its answer, through the answer id.
  5. button::after The "+" that becomes "−" when it opens. Write content: "+" / "": without this empty alternative text, the browser adds the sign to the question name.

HTML code

HTML
<div class="accordion">
  <!-- Question 1: the heading holds the button that opens the answer -->
  <h3>
    <button
      type="button"
      id="accordion-button-1"
      aria-expanded="true"
      aria-controls="accordion-panel-1"
    >
      How do I change my password?
    </button>
  </h3>
  <!-- Answer 1: tied by aria-controls, closed by the script.
       No role="region": Chrome would keep each closed answer
       (until-found) in the list of landmarks, empty. -->
  <div id="accordion-panel-1">
    <p>
      In My account, open Security, then Change password.
      An email confirms the change.
    </p>
  </div>
  <!-- Questions 2 to 4: same structure -->
</div>

<script>
  // Without JavaScript, every answer stays readable:
  // the script closes them on load, then handles each button
  const buttons = document.querySelectorAll(
    '.accordion button[aria-controls]'
  );

  const panelOf = (button) =>
    document.getElementById(button.getAttribute('aria-controls'));

  // until-found: Ctrl+F finds the text of a closed answer
  const toggle = (button, open) => {
    button.setAttribute('aria-expanded', String(open));
    if (open) {
      panelOf(button).removeAttribute('hidden');
    } else {
      panelOf(button).setAttribute('hidden', 'until-found');
    }
  };

  for (const button of buttons) {
    toggle(button, false);
    button.addEventListener('click', () => {
      const open = button.getAttribute('aria-expanded') !== 'true';
      toggle(button, open);
    });
    // The browser's find in page opens the answer that holds the text
    panelOf(button).addEventListener('beforematch', () => {
      toggle(button, true);
    });
  }
</script>
CSS
/* The button takes the full width of the heading */
.accordion h3 button {
  display: flex;
  width: 100%;
  align-items: center;
  text-align: left;
}

/* A "+" that becomes "−". The empty alternative text keeps
   the sign out of the question name. The 1st declaration is
   a fallback for browsers that ignore this alternative text. */
.accordion h3 button::after {
  content: "−";
  content: "−" / "";
  margin-left: auto;
}

.accordion h3 button[aria-expanded="false"]::after {
  content: "+";
  content: "+" / "";
}

Usage

Use it for

  • An FAQ or extra details that not everyone reads.
  • Short answers, read one at a time.
  • Questions people find through headings, with the screen reader's list of headings.

Avoid it for

  • Hiding a price, or a condition that decides a purchase.
  • Removing the marker without replacing it: nothing shows anymore that the question opens.
  • A clickable div instead of a button: no role, no state, no keyboard access.
  • The region role on every answer: the APG advises against it beyond 6 open answers, and with hidden="until-found" Chrome keeps the closed answers in the list of landmarks, empty.

Accessibility

ARIA roles and attributes

The heading and the button come from HTML. ARIA adds the button state and the link to the answer. The answers do not have the region role, which the APG leaves optional.

AttributeElementDescription
aria-expanded="false"buttonSays whether the answer is closed (false) or open (true). The script updates it on each click.
aria-controls="accordion-panel-1"buttonTies the question to its answer, through the answer id.
hidden="until-found"Answer divHides the closed answer and removes it from the accessibility tree. The browser's find in page (Ctrl+F) finds its text, fires the beforematch event, and the script opens the question. Browsers that do not support it treat it as hidden.
content: "+" / ""button::afterThe empty alternative text keeps the sign out of the name: the screen reader gets "How do I change my password?", without the "+".

Keyboard navigation

KeyAction
TabMoves from one question to the next, then into the open answer if it holds a link
Enter or SpaceOpens or closes the answer of the focused question

Tab order

  1. "How do I change my password?"
  2. "How do I change my email address?". Its open answer holds no link: Tab goes straight to the next question.
  3. "Where can I find my invoices?"
  4. "How do I delete my account?"

With a screen reader

Example in Chrome with VoiceOver in English. The wording changes from one screen reader to another.

ActionAnnouncement
Tab to a question"How do I change my password?", "collapsed", "button"
Enter"expanded"
Next item (VO + →)The answer, "In My account, open Security…"
List of headings (rotor)The 4 questions, at level 3

Related RGAA criteria

The RGAA is the French accessibility standard. Its 106 criteria test WCAG 2.1 level AA.

Criterion 7.1: Is each script, where needed, compatible with assistive technologies?
Each question is a button with a name and the aria-expanded state, tied to its answer.
Criterion 7.3: Can each script be controlled with the keyboard and with any pointing device (except in specific cases)?
Each question opens and closes from the keyboard, with Enter or Space.
Criterion 9.1: On each web page, is information structured with an appropriate use of headings?
Each question is an h3 heading, under the section heading.
Criterion 10.7: On each web page, for each element that receives focus, is the focus visible?
Focus stays visible on each question, open or closed.
Criterion 10.8: On each web page, are hidden contents meant to be ignored by assistive technologies?
A closed answer is ignored by assistive technologies.

Other references

Beyond the APG

New featureWhat it addsBrowsersIn the demo
hidden="until-found"The browser's find in page (Ctrl+F) finds the text of a closed answer, and the script opens its question.Chrome 102+, Firefox 148+Yes
details nameAn exclusive accordion with no script, at the cost of the heading level.Chrome 120+, Firefox 130+, Safari 17.2+Variation
::details-contentAnimates the opening of a details in CSS.Chrome 131+, Firefox 143+, Safari 18.4+No

Variations

One answer open at a time

Opening a question closes the one that was open. The script goes through the other buttons and sets them back to aria-expanded="false".

In My account, open Security, then Change password. An email confirms the change.

In My account, open Profile and enter the new address. It becomes active once you click the link you receive.

In My account, open Orders: each order has its invoice as a PDF.

Write to customer service from the Contact page. The account and its data are deleted within 30 days.

Code
// In the script: opening an answer closes the others
if (open) {
  for (const other of buttons) {
    if (other !== button) {
      toggle(other, false);
    }
  }
}

Open on load

The 1st answer stays open when the page shows: its button keeps aria-expanded="true".

In My account, open Security, then Change password. An email confirms the change.

In My account, open Profile and enter the new address. It becomes active once you click the link you receive.

In My account, open Orders: each order has its invoice as a PDF.

Write to customer service from the Contact page. The account and its data are deleted within 30 days.

Code
// In the script: the 1st answer stays open on load
buttons.forEach((button, index) => toggle(button, index === 0));

Native version with details

Without JavaScript, details and summary provide the button and its state, and the same name opens only one answer at a time. The summary has no heading level: the question is missing from the list of headings. An h3 inside the summary does not fix it, since depending on the browser and screen reader pair you lose either the heading or the button role.

How do I change my password?

In My account, open Security, then Change password. An email confirms the change.

How do I change my email address?

In My account, open Profile and enter the new address. It becomes active once you click the link you receive.

Where can I find my invoices?

In My account, open Orders: each order has its invoice as a PDF.

How do I delete my account?

Write to customer service from the Contact page. The account and its data are deleted within 30 days.

Code
<!-- The summary acts as the button, with no heading level -->
<details name="faq">
  <summary>How do I change my password?</summary>
  <p>…</p>
</details>
<details name="faq">
  <summary>How do I change my email address?</summary>
  <p>…</p>
</details>