Project 03 · Beginner
Accordion
A list of questions where clicking one shows its answer. You'll build it three ways, each adding one idea: first with no JavaScript, then with a button that toggles each answer, then keeping only one answer open.
- 4 HTML ideas
- 4 CSS ideas
- 6 JavaScript ideas
- 3 ways to build it
Why build it
Showing and hiding content is how you build FAQ pages, collapsible sections in long articles, filter groups in a shop's sidebar, and expandable rows on a settings page. You'll also know when the browser's own details element is enough, and when to write JavaScript for something it can't do, like keeping only one section open.
Skills you'll earn
- Show and hide content with details and summary, with no JavaScript at all
- Build toggle buttons that tell screen readers whether they're open, with aria-expanded
- Find every matching element and run the same code on each, with querySelectorAll and forEach
- Keep only one section open at a time by closing the rest first
01
Try it
Play with it first, then read the code that makes it work.
The browser's own <details> element opens and closes each answer, with no JavaScript at all. Several answers can be open at once.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accordion</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="faq">
<h1 class="faq-title">How a web page works</h1>
<div class="accordion">
<!-- <details> hides everything except its <summary> until it's opened. -->
<details class="accordion-item">
<!-- <summary> is the clickable part, and works with a keyboard for free. -->
<summary class="accordion-trigger">
What does HTML do?
<span class="accordion-icon" aria-hidden="true">+</span>
</summary>
<div class="accordion-panel">
<p>
HTML gives the page its structure: headings, paragraphs, buttons,
and links. It says what each thing is.
</p>
</div>
</details>
<details class="accordion-item">
<summary class="accordion-trigger">
What does CSS do?
<span class="accordion-icon" aria-hidden="true">+</span>
</summary>
<div class="accordion-panel">
<p>
CSS decides how everything looks: colours, sizes, spacing, and
where things sit on the screen.
</p>
</div>
</details>
<details class="accordion-item">
<summary class="accordion-trigger">
What does JavaScript do?
<span class="accordion-icon" aria-hidden="true">+</span>
</summary>
<div class="accordion-panel">
<p>
JavaScript makes the page respond. It listens for things like clicks
and key presses, then changes the page to match.
</p>
</div>
</details>
</div>
</main>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
The browser's own <details> element opens and closes each answer, with no JavaScript at all. Several answers can be open at once.
HTMLthe structure
- 01
Let
<details>and<summary>do the work<details>is an HTML element that hides its content until someone opens it. The<summary>inside it stays visible and works as the clickable question. Clicking it, or pressing Enter or Space when it has focus, shows or hides the rest. The browser handles all of this, including telling screen readers whether it's open or closed, so this way needs no JavaScript.<!-- <details> hides everything except its <summary> until it's opened. --> <details class="accordion-item"> <!-- <summary> is the clickable part, and works with a keyboard for free. --> <summary class="accordion-trigger"> What does HTML do? <span class="accordion-icon" aria-hidden="true">+</span> </summary> <div class="accordion-panel"> <p> HTML gives the page its structure: headings, paragraphs, buttons, and links. It says what each thing is. </p> </div> </details>
CSSthe look
- 02
Swap the built-in arrow for your own icon
Browsers draw a small triangle next to every
<summary>to show it can open. This design uses its own + icon, so the triangle has to go.list-style: noneremoves it in most browsers, because they draw it like a list bullet. Safari draws it as::-webkit-details-markerinstead, so a second rule hides that withdisplay: none./* Hide the browser's own arrow; the + icon replaces it. */ list-style: none; } /* Safari draws its arrow differently, so it needs its own rule. */ .accordion-trigger::-webkit-details-marker { display: none; } - 03
Style the open state with
[open]An attribute is extra information written in an element's tag, like
classorhref. The browser adds theopenattribute to<details>while it's showing, and removes it when it closes. CSS can select elements by attribute with square brackets, so.accordion-item[open] .accordion-iconrotates the + by 45 degrees into a ×, with no JavaScript./* The browser adds [open] to <details> while it's showing: + turns into ×. */ .accordion-item[open] .accordion-icon { transform: rotate(45deg); }
HTMLthe structure
- 01
Put a
<button>inside each headingScreen reader users often move around a page by jumping from heading to heading, like scanning a table of contents. Making each question an
<h2>gives them that shortcut. The<button>inside the heading is the part that does something: it can be clicked, reached with Tab, and pressed with Enter or Space.<!-- Each question is a heading, so screen readers can jump between them. --> <h2 class="accordion-heading"> <!-- aria-expanded says if the panel is open; aria-controls points at it. --> <button type="button" class="accordion-trigger" aria-expanded="false" aria-controls="panel-html" > What does HTML do? <span class="accordion-icon" aria-hidden="true">+</span> </button> </h2> - 02
Say what's open with
aria-expandedARIA attributes give screen readers information that plain HTML can't.
<details>reported open or closed on its own, but a button doesn't know it controls anything.aria-expandedsays whether its panel is open,"true"or"false", so screen readers announce "expanded" or "collapsed".aria-controlsholds the panel'sid, linking the button to the content it shows.<!-- aria-expanded says if the panel is open; aria-controls points at it. --> <button type="button" class="accordion-trigger" aria-expanded="false" aria-controls="panel-html" > - 03
Hide panels with the
hiddenattributeThe
hiddenattribute tells the browser not to show an element at all. It takes up no space, and screen readers skip it too, so a closed answer isn't read out. Every panel starts withhiddenin the HTML, and JavaScript removes it to open one by settingpanel.hiddentofalse.<!-- hidden takes the panel off the page and away from screen readers. --> <div class="accordion-panel" id="panel-html" hidden> <p> HTML gives the page its structure: headings, paragraphs, buttons, and links. It says what each thing is. </p> </div>
CSSthe look
- 04
Make the whole row clickable
Buttons come with their own border, background and font.
border: 0,background: noneandfont: inheritremove those so the button matches the page.width: 100%stretches it across the row, and flexbox withjustify-content: space-betweenpushes the question to one side and the + to the other. A click anywhere on the row works, not only on the words./* The button fills the whole row, so the whole row is clickable. */ .accordion-trigger { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 1.25rem 1.5rem; border: 0; background: none; font: inherit; font-weight: 600; text-align: left; color: inherit; cursor: pointer; } - 05
Style from the same attribute JavaScript sets
CSS can select elements by an attribute's value with square brackets.
[aria-expanded="true"]matches a button whose panel is open, and rotates its + into a ×. JavaScript already sets that attribute for screen readers, so CSS reuses it instead of a separate class. With one attribute behind both, what's shown and what's announced can't disagree./* The attribute JavaScript sets is also what CSS styles: + turns into ×. */ .accordion-trigger[aria-expanded="true"] .accordion-icon { transform: rotate(45deg); }
JavaScriptthe behaviour
- 06
Find every match with
querySelectorAllquerySelectorreturns only the first element that matches a selector.querySelectorAllreturns every match, in a list called a NodeList. Here it finds all three.accordion-triggerbuttons at once and stores them intriggers, so the code can set them all up together instead of finding each one separately.// querySelectorAll finds every trigger, not just the first one. const triggers = document.querySelectorAll(".accordion-trigger"); - 07
Run the same code for each item with
forEachA loop runs the same code several times.
forEachis a loop that lists have built in: you give it a function, and it calls that function once for each item, passing the item in. Here eachtriggergets its own click listener, so you write the listener once and all three buttons use it.// forEach runs the same code once for each trigger. triggers.forEach((trigger) => { trigger.addEventListener("click", () => { // aria-controls holds the id of the panel this button opens. const panelId = trigger.getAttribute("aria-controls"); const panel = document.getElementById(panelId); // Attributes are always strings, so compare with "true", not true. const isOpen = trigger.getAttribute("aria-expanded") === "true"; // Flip both together: the button's state and the panel's visibility. trigger.setAttribute("aria-expanded", !isOpen); panel.hidden = isOpen; }); }); - 08
Remember that attributes are strings
An attribute's value is always text, called a string, even when it looks like
true. SogetAttribute("aria-expanded")returns"true"in quotes, not the booleantrue. Comparing it with=== "true"turns it into a real boolean,isOpen, for the rest of the code. Writing=== truewould always be false, so every click would open the panel and none would close it.// Attributes are always strings, so compare with "true", not true. const isOpen = trigger.getAttribute("aria-expanded") === "true"; - 09
Flip the state with
!A boolean is a value that's either
trueorfalse, and!flips it, so!isOpenis the opposite ofisOpen. First,getElementById()finds the panel using theidstored inaria-controls. Then the button'saria-expandedis set to!isOpenand the panel'shiddentoisOpen, side by side. Changing both together means the button never says open while its panel is hidden.// aria-controls holds the id of the panel this button opens. const panelId = trigger.getAttribute("aria-controls"); const panel = document.getElementById(panelId); // Attributes are always strings, so compare with "true", not true. const isOpen = trigger.getAttribute("aria-expanded") === "true"; // Flip both together: the button's state and the panel's visibility. trigger.setAttribute("aria-expanded", !isOpen); panel.hidden = isOpen;
JavaScriptthe behaviour
- 01
Write one function for open and closed
A function can take parameters, values you pass in each time you call it.
setPanelOpen()takes two: atriggerbutton, andisOpen, eithertrueorfalse. It finds that button's panel, setsaria-expandedtoisOpen, and setshiddento the opposite. Opening one panel now means closing others too, so both jobs share this one function instead of repeating the code.// Open or close one panel, and keep its button's aria-expanded in step. function setPanelOpen(trigger, isOpen) { const panelId = trigger.getAttribute("aria-controls"); const panel = document.getElementById(panelId); trigger.setAttribute("aria-expanded", isOpen); panel.hidden = !isOpen; } - 02
Close everything, then open one
When a question is clicked, the code first saves whether its panel
wasOpen. Then it loops over every trigger and closes its panel, which is simpler than working out which one is open. Last,if (!wasOpen)opens this panel only if it was closed before. If it was already open, it stays closed, so a second click on the same question closes it.// Close every panel first, so only one is ever open. triggers.forEach((otherTrigger) => setPanelOpen(otherTrigger, false)); // Then open this one, unless it was already open: a second click closes it. if (!wasOpen) { setPanelOpen(trigger, true); }
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Animate the panels opening and closing, and skip the animation for reduced motion.
- Challenge 02
Add Expand all and Collapse all buttons.
- Challenge 03
Open the panel named in the page's address, like #panel-css, when the page loads.
Learn more on the blog
Articles that go further into ideas from this project.
Finished Accordion?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.