What is an accordion?
I think of an accordion as a set of drawers. Each heading gives you a glimpse of what’s tucked underneath. You open the ones you need and leave the rest closed until you want to dig into the details. That’s handy when you’re looking up one question in an FAQ or jumping to a step in a guide.
I wrote this to slow down and really understand what happens in a single click. How does the button know which panel to open? What makes the plus turn into a minus? How does a screen reader know the panel is open? Let’s take one apart and see how those pieces fit together.
The anatomy
Each accordion item pairs a question with the panel it opens. The heading gives the question a place in the page’s outline and the real button inside it does a lot quiet work for us. You can Tab to it and press Enter or Space to open the panel.
Try highlighting Button and then Panel below. You can see which pieces go together. In the code, an ID and an ARIA attribute connect them. In the Behind the Buttons section, we’ll look at how the button points to its panel and tells a screen reader whether it’s open.
Squirrel Secrets
Yes! Squirrels sometimes forget where they buried their nuts. The forgotten seeds can eventually sprout into new plants and trees.
Hover over a part below, or tab to it, to highlight it in the example.Click a part below to highlight it in the example.
Wrap the button in a heading, like
<h3>, so screen-reader users can jump between items. Pick the level that fits the page, and keep the button as the only thing inside it.
Build it in vanilla JavaScript
We’ve seen how a button points to its panel. Now let’s give it something to do. I’m starting with plain HTML, CSS, and JavaScript so we can follow one click all the way through before we rebuild it in React.
Start with the markup
The first squirrel question starts open. The second one starts closed. Look at what’s different in the HTML. The second button has aria-expanded="false", and its panel has hidden. One describes the state of the button, and the other keeps the panel out of view.
<div class="accordion">
<div class="accordion-item">
<h3>
<button
type="button"
class="accordion-button"
aria-expanded="true"
aria-controls="panel-1"
>
Are squirrels secretly tiny gardeners?
</button>
</h3>
<div id="panel-1">
<p>Yes! Squirrels sometimes forget where they buried their nuts…</p>
</div>
</div>
<div class="accordion-item">
<h3>
<button
type="button"
class="accordion-button"
aria-expanded="false"
aria-controls="panel-2"
>
Why do squirrels flick their tails so dramatically?
</button>
</h3>
<div id="panel-2" hidden>
<p>Tail flicking helps squirrels communicate…</p>
</div>
</div>
</div>Add a little CSS
Just enough CSS to look like an accordion. The bits to watch here are the plus and minus icons, which come from the button’s aria-expanded attribute. When it’s true, the CSS swaps the plus for a minus. Since the JavaScript already updates that attribute, the icon always matches the real state, with no extra code.
.accordion-button {
display: flex;
justify-content: space-between;
width: 100%;
padding: 1rem 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
/* The icon reads aria-expanded, so it can never disagree with the state.
The "/ ''" hides the symbol from screen readers. The state is already announced. */
.accordion-button::after {
content: "+" / "";
}
.accordion-button[aria-expanded="true"]::after {
content: "−" / "";
}Wire it up with JavaScript
The script follows the mental model above, step by step. The first two steps set up the buttons. The last three happen every time you click one.
- Select: the buttons inside each accordion.
- Listen: for a click on each button.
- Locate panel: by matching the button’s
aria-controlsto the panel’sid. - Read state: by comparing
aria-expandedto the string"true". - Update both: the button’s
aria-expandedand the panel’shiddenproperty.
That’s the whole script:
// Select: find each accordion, then only the buttons inside it
document.querySelectorAll(".accordion").forEach((accordion) => {
const buttons = accordion.querySelectorAll(".accordion-button");
buttons.forEach((button) => {
// Listen: run this when the button is clicked
button.addEventListener("click", () => {
// Locate panel: aria-controls holds the panel's id
const panel = document.getElementById(button.getAttribute("aria-controls"));
// Read state: attributes are text, so compare with "true"
const isOpen = button.getAttribute("aria-expanded") === "true";
// Update both: set the opposite state, and hide or show the panel to match
button.setAttribute("aria-expanded", String(!isOpen));
panel.hidden = isOpen;
});
});
});That last step is worth reading slowly. If the second panel starts closed, isOpen is false. After the click, the button gets aria-expanded="true" and the panel gets hidden = false. Both lines are working from what was true before the click.
Keep the button + panel sync
aria-expanded is the single source of truth. The JavaScript checks it to decide whether to open or close an item, and the CSS uses it to show the plus or minus. It also tells screen readers whether the item is open. Since everything reads from the same place, what people see and what screen readers announce always match.
Make it single-open
In the example above, several items can be open at once. If you want opening one to close the other, a little setOpen helper lets you update a button and its panel together. Use it to close the other items first, then toggle the one you clicked. Click an open question again and you can close them all.
function setOpen(button, open) {
button.setAttribute("aria-expanded", String(open));
document.getElementById(button.getAttribute("aria-controls")).hidden = !open;
}
document.querySelectorAll(".accordion").forEach((accordion) => {
const buttons = accordion.querySelectorAll(".accordion-button");
buttons.forEach((button) => {
button.addEventListener("click", () => {
const isOpen = button.getAttribute("aria-expanded") === "true";
// Close every other item in this accordion first
buttons.forEach((other) => {
if (other !== button) setOpen(other, false);
});
// Then open or close the one that was clicked
setOpen(button, !isOpen);
});
});
});Keyboard and focus
Because each header holds a real <button>, Tab, Enter and Space already work, with no extra code (see Behind the Buttons). This section covers what happens next: moving between headers, seeing where you are, and making sure focus never lands somewhere it shouldn’t.
Make focus visible
When you Tab to a question, can you tell which one you’re on? That’s what the focus ring is for. I’m using :focus-visible to give the button a clear outline when the browser decides focus should be visible, like when you Tab to it.
.accordion-button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}Leave focus on the button
After someone opens an item, keep focus on its button. It can be tempting to move focus into the panel, but that pulls keyboard and screen-reader users somewhere they didn’t ask to go. From the button, they can keep reading into the panel when they’re ready, or close it again with Enter or Space.
Keep hidden content out of reach
hidden does a lot for free: a closed panel is invisible, silent to screen readers and skipped by Tab. The catch comes when you animate it. Animations need the panel to stay in the page while it shrinks, so instead of hidden, the panel is collapsed with CSS. Now it only looks closed, and any link inside can still be reached with Tab, landing focus somewhere nobody can see.
The fix is inert. It keeps a closed panel out of the Tab order and away from screen readers, while CSS handles the animation. Like hidden, it also keeps a closed panel’s text out of the browser’s find-in-page (Cmd+F) and stops it from being selected, which is worth knowing for long, reference-style panels. Switching takes three changes:
- Markup: on closed panels, replace
hiddenwithinert. A panel that keepshiddenstaysdisplay: none, so the CSS can never open it. Give each panel a class and one inner wrapper for the animation to work on. - JavaScript: in the first script, swap
panel.hidden = isOpenforpanel.inert = isOpen. In the single-open version’ssetOpenhelper, swap.hidden = !openfor.inert = !open. - CSS: open the panel whenever its button says
aria-expanded="true".
<!-- A closed panel: inert instead of hidden -->
<div id="panel-2" class="accordion-panel" inert>
<div>
<p>Tail flicking helps squirrels communicate…</p>
</div>
</div>.accordion-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-panel > * {
min-height: 0;
overflow: hidden;
}
/* Open when its button says so */
.accordion-item:has([aria-expanded="true"]) .accordion-panel {
grid-template-rows: 1fr;
}
@media (prefers-reduced-motion: reduce) {
.accordion-panel {
transition: none;
}
}Arrow keys, Home and EndOptional
For longer accordions, you can add a few shortcuts on top of Tab: Up and Down move between headers, and Home and End jump to the first and last. They aren’t part of the current WAI-ARIA accordion pattern, but they’re a nice touch. A single listener on each accordion handles them, so it works for any number of items.
document.querySelectorAll(".accordion").forEach((accordion) => {
// One listener per accordion handles every header inside it
accordion.addEventListener("keydown", (event) => {
const keys = ["ArrowDown", "ArrowUp", "Home", "End"];
if (!keys.includes(event.key)) return;
const buttons = [...accordion.querySelectorAll(".accordion-button")];
const index = buttons.indexOf(document.activeElement);
if (index === -1) return; // focus isn't on a header button
const last = buttons.length - 1;
let next;
if (event.key === "ArrowDown") next = index === last ? 0 : index + 1;
if (event.key === "ArrowUp") next = index === 0 ? last : index - 1;
if (event.key === "Home") next = 0;
if (event.key === "End") next = last;
event.preventDefault(); // stop the page from scrolling
buttons[next].focus();
});
});preventDefault() matters here because without it, the arrow keys would also scroll the page.
Rebuild it in React
Same accordion, same mental model, now as a React component. The big change is where the source of truth lives. In the vanilla version, the script read aria-expanded from the button to figure out what was open. In React, the open item lives in state, and the attributes are drawn from that state every time the component renders. You never call setAttribute, and both attributes come from the same isOpen value.
Start with the data
Instead of writing each item by hand, keep the questions and answers in an array. Each item gets a shortid, which React uses to tell items apart and which we’ll use to connect each button to its panel.
const items = [
{
id: "gardeners",
question: "Are squirrels secretly tiny gardeners?",
answer: "Yes! Squirrels sometimes forget where they buried their nuts…",
},
{
id: "tails",
question: "Why do squirrels flick their tails so dramatically?",
answer: "Tail flicking helps squirrels communicate…",
},
];Choosing the state
What does the accordion actually need to remember? Here, it’s just which question is open. So we store openId or null when everything is closed. Storing a single id also gives us single-open behavior for free, because only one id fits. Passing a function to setOpenId hands you the current value, so the toggle always works from the latest state.
const [openId, setOpenId] = useState(null);
function toggle(id) {
// Clicking the open item closes it; any other item opens instead
setOpenId((current) => (current === id ? null : id));
}Render ARIA from state
For each item, work out isOpen, then let it drive everything: aria-expanded, hidden, and which way the toggle goes. React turns aria-expanded={true} into the text "true" for you.
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div className="accordion-item" key={item.id}>
<h3>
<button
type="button"
className="accordion-button"
aria-expanded={isOpen}
aria-controls={`panel-${item.id}`}
onClick={() => toggle(item.id)}
>
{item.question}
</button>
</h3>
<div id={`panel-${item.id}`} hidden={!isOpen}>
<p>{item.answer}</p>
</div>
</div>
);
})}Give each panel a unique ID
Ids like panel-gardeners work until two accordions share a page and both use the same item ids. Then aria-controls points at the wrong panel. React’s useId gives each accordion its own prefix, so every id is unique.
const baseId = useId(); // unique to this accordion
// Inside .map():
const panelId = `${baseId}-panel-${item.id}`;Put it together
Here’s the whole component. Pass it your items and it handles the rest. Keyboard shortcuts work the same way as in Keyboard and focus, if you want to add them.
import { useId, useState } from "react";
export default function Accordion({ items }) {
const [openId, setOpenId] = useState(null);
const baseId = useId();
function toggle(id) {
setOpenId((current) => (current === id ? null : id));
}
return (
<div className="accordion">
{items.map((item) => {
const isOpen = openId === item.id;
const panelId = `${baseId}-panel-${item.id}`;
return (
<div className="accordion-item" key={item.id}>
{/* Use the heading level that fits your page */}
<h3>
<button
type="button"
className="accordion-button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(item.id)}
>
{item.question}
</button>
</h3>
<div id={panelId} hidden={!isOpen}>
<p>{item.answer}</p>
</div>
</div>
);
})}
</div>
);
}
// Use it anywhere:
<Accordion items={items} />Let several open at onceOptional
To let more than one item stay open, store a list of open ids instead of one. Clicking adds an id to the list or removes it, and each item checks whether its id is in the list. Nothing else in the component changes.
const [openIds, setOpenIds] = useState([]);
function toggle(id) {
setOpenIds((current) =>
current.includes(id)
? current.filter((openId) => openId !== id) // close it
: [...current, id] // open it, leave the others alone
);
}
// Inside .map():
const isOpen = openIds.includes(item.id);Test it
You don’t need special tools to check your accordion. A Mac and a browser cover the essentials, so run through these to see if the accordion behaves the way we expect.
- Use only the keyboard. Click the address bar, then press Tab to move through the page. Every header should be reachable, and Enter or Space should open and close it. In Safari, first turn on Settings → Advanced → “Press Tab to highlight each item on a webpage.”
- Watch the focus. While you tab, you should always be able to see which header you’re on. Closed answers should be skipped completely, so focus never lands somewhere invisible.
- Listen with a screen reader. Press Cmd+F5 to turn on VoiceOver, then Tab to a header. You should hear its question, that it’s a button, and that it’s collapsed. Press Space and it should tell you it’s now expanded. Press Ctrl+Option+U to open the rotor, and your questions should appear in its Headings list. Press Cmd+F5 again to turn it off.
- Zoom to 200%. Press Cmd and + until the browser shows 200%. Nothing should overlap, get cut off or need sideways scrolling. Cmd+0 resets it.
- Try on reduced motion. In System Settings → Accessibility → Display, switch on Reduce motion. Items should open and close without animating.
- Run an automated check. Lighthouse’s Accessibility audit can flag things you might miss, like low contrast. I’d still start with the keyboard. That’s how you find out what happens when you actually try to use the accordion.
Keep going
An accordion looks simple, but most of it is invisible. What helped me was taking one question and following it all the way through. Which panel opens? What changes on the button? Where does focus stay? Once I could answer those, I knew where to look if something felt off.
Want to go deeper? These are the references I lean on most:
- WAI-ARIA accordion pattern (opens in a new tab), the reference pattern, including keyboard support.
- aria-expanded on MDN (opens in a new tab), with notes on where it does and doesn’t belong.
- inert on MDN (opens in a new tab), for keeping hidden content out of reach.
Want to see the finished version? My accordion, with its title, markers and open-and-close animation, lives in my Sedge design system.
More Building Blocks are on the way. 🌱