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
h3The heading that holds the question and puts it in the page's list of headings.buttonThe question. Itsaria-expandedattribute gives the state, collapsed or expanded.divThe answer, hidden withhidden="until-found"while the question is closed: the browser's find in page still finds its text and opens it.aria-controlsTies the button to its answer, through the answerid.button::afterThe "+" that becomes "−" when it opens. Writecontent: "+" / "": without this empty alternative text, the browser adds the sign to the question name.
HTML code
<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>/* 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
divinstead of abutton: no role, no state, no keyboard access. - The
regionrole on every answer: the APG advises against it beyond 6 open answers, and withhidden="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.
| Attribute | Element | Description |
|---|---|---|
aria-expanded="false" | button | Says whether the answer is closed (false) or open (true). The script updates it on each click. |
aria-controls="accordion-panel-1" | button | Ties the question to its answer, through the answer id. |
hidden="until-found" | Answer div | Hides 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::after | The empty alternative text keeps the sign out of the name: the screen reader gets "How do I change my password?", without the "+". |
Keyboard navigation
| Key | Action |
|---|---|
| Tab | Moves from one question to the next, then into the open answer if it holds a link |
| Enter or Space | Opens or closes the answer of the focused question |
Tab order
- "How do I change my password?"
- "How do I change my email address?". Its open answer holds no link: Tab goes straight to the next question.
- "Where can I find my invoices?"
- "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.
| Action | Announcement |
|---|---|
| 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-expandedstate, 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
h3heading, 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
WCAG 2.2
EN 301 549
- Accessibility requirements for ICT products and services (V4.1.1, PDF)Clauses 9.1.3.1, 9.2.1.1, 9.2.4.7, 9.4.1.2
Beyond the APG
| New feature | What it adds | Browsers | In 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 name | An exclusive accordion with no script, at the cost of the heading level. | Chrome 120+, Firefox 130+, Safari 17.2+ | Variation |
::details-content | Animates 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.
// 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.
// 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.
<!-- 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>