details and summary
Two elements do the whole job with no script, right up until somebody hits print.
<details>
<summary>How long does delivery take?</summary>
<p>Two to three business days.</p>
</details>
That’s an accordion. It’s in the tab order and it opens and closes with a click, Enter or Space. It even announces its expanded state without an aria-expanded anywhere. The browser owns that state, not you.
I built this by hand on four projects, with a click handler and a class toggle and an ARIA attribute I sometimes remembered, before I finally sat down and read what the element already did.
The open attribute makes it start expanded. The browser adds and removes it as people click. That makes details[open] your styling hook.
The disclosure triangle is the one part that differs by browser. Firefox draws it as a list marker, so list-style: none on the summary gets rid of it. Chrome and Safari draw their own and want summary::-webkit-details-marker { display: none; } instead. Write both if you want a marker of your own.
The catch is printing. A closed <details> is closed on paper too, and so a page of FAQs prints as a column of questions with nothing under them. No stylesheet fixes that because the browser doesn’t render a closed one’s contents, whatever you set their display to. So it takes a few lines of script after all.
window.addEventListener('beforeprint', () => {
document.querySelectorAll('details').forEach((details) => {
details.open = true;
});
});
That leaves everything open once the print dialog closes. If that bothers you, remember which ones you opened and close them again on afterprint.
Which is the general shape of these. The platform gives you the behavior and leaves you the one edge case it couldn’t guess.