An accordion shows a list of headings, and each one expands to reveal content. It is the right pattern for FAQs.
<style>
.accordion { font-family: system-ui, sans-serif; max-width: 360px; }
.accordion h3 { margin: 0; }
.accordion button { width: 100%; display: flex; justify-content: space-between; align-items: center; font: inherit; font-weight: 600; text-align: left; padding: 10px 12px; background: #f4f9fc; border: 1px solid #c9dbe6; cursor: pointer; }
.accordion button:focus-visible { outline: 3px solid #d94a00; outline-offset: -3px; }
.accordion button::after { content: "+"; font-size: 20px; }
.accordion button[aria-expanded="true"]::after { content: "−"; }
.panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .25s ease; border: 1px solid #c9dbe6; border-top: 0; }
.panel > div { overflow: hidden; }
.panel p { margin: 0; padding: 10px 12px; }
.panel.open { grid-template-rows: 1fr; }
@media (prefers-reduced-motion: reduce) { .panel { transition: none; } }
</style>
<div class="accordion" id="faq">
<h3><button aria-expanded="false" aria-controls="a1" id="q1">What is JavaScript?</button></h3>
<div class="panel" id="a1" role="region" aria-labelledby="q1"><div><p>The programming language of the web.</p></div></div>
<h3><button aria-expanded="false" aria-controls="a2" id="q2">Do I need HTML and CSS?</button></h3>
<div class="panel" id="a2" role="region" aria-labelledby="q2"><div><p>They help a lot, because JavaScript works on both.</p></div></div>
<h3><button aria-expanded="false" aria-controls="a3" id="q3">Is it free to learn?</button></h3>
<div class="panel" id="a3" role="region" aria-labelledby="q3"><div><p>Yes. All you need is a browser.</p></div></div>
</div>
<script>
const accordion = document.querySelector("#faq");
const singleOpen = true; // set to false to allow several open at once
function setOpen(button, open) {
button.setAttribute("aria-expanded", String(open));
document.getElementById(button.getAttribute("aria-controls")).classList.toggle("open", open);
}
accordion.addEventListener("click", event => {
const button = event.target.closest("button[aria-expanded]");
if (!button) return;
const willOpen = button.getAttribute("aria-expanded") !== "true";
if (singleOpen && willOpen) {
accordion.querySelectorAll("button[aria-expanded='true']").forEach(other => setOpen(other, false));
}
setOpen(button, willOpen);
});
</script>
How it works
- Each header is a
<button>inside a heading. Enter and Space work with no extra code. aria-expandedsays whether it is open, andaria-controlspoints at the panel. The state lives in one place:setOpen()updates both the ARIA attribute and the visible state.- The animation uses CSS grid (
grid-template-rows: 0frto1fr), which animates to the content’s natural height without measuring it in JavaScript. It is turned off for people who prefer reduced motion. - Event delegation: one listener on the whole accordion.
Closed panels are hidden from assistive technology?
The panels animate to zero height with overflow: hidden, but their text is still in the page. For stricter behaviour, also set hidden or inert on closed panels, or use the native <details> element. It handles most of this with no JavaScript:
<details>
<summary>What is JavaScript?</summary>
<p>The programming language of the web.</p>
</details>
Try changing it
- Set
singleOpen = falseto allow many panels at once. - Add Expand all and Collapse all buttons.
- Open the panel whose id is in the URL hash.
- Replace the whole thing with
<details name="faq">(a sharednamemakes only one open at a time in current browsers).