Frontend fundamentals

How to build an accordion that works with a mouse, keyboard or screen reader, first in vanilla JavaScript, then in React with my design system component as the example.Building an accordion that works with a keyboard and screen reader.

Building Blocks

Accordion

· 12 min readComponents, JavaScript, React, Accessibility

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.

An open accordion item. Its parts are described in the list below.

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.

  1. 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.

Behind the Buttons

An accordion has an invisible layer of structure. Let’s look at the HTML for the squirrel question. The button has two little bits of information tucked into it. They’re called ARIA attributes. One tells a screen reader whether the panel is open and the other points to the panel this button controls. JavaScript does the opening and closing, and the attributes describe what’s going on.

<div class="accordion-item">
  <h3>
    <button type="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>

aria-expanded

Is it open? Here, aria-expanded="true" tells a screen reader the button is expanded. You might hear something like “Are squirrels secretly tiny gardeners? Button, expanded.” When we close the panel, JavaScript changes the value to false. The plus and minus give you a visual clue, and aria-expanded makes that same information available to a screen reader.

aria-controls

Which panel goes with this button? Follow aria-controls="panel-1" down a few lines and you’ll find id="panel-1" on the panel. Those values have to match. In the next section, we’ll use that same ID to find the panel in JavaScript.

aria-labelledbyOptional

Sometimes a panel has enough content that it helps to make it a landmark people can jump to with a screen reader. If you give the panel role="region", use aria-labelledby to point back to the button’s id. That way the region gets its name from the question. For a short answer about squirrels, I’d skip both.

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.

  1. Select: the buttons inside each accordion.
  2. Listen: for a click on each button.
  3. Locate panel: by matching the button’s aria-controls to the panel’s id.
  4. Read state: by comparing aria-expanded to the string "true".
  5. Update both: the button’s aria-expanded and the panel’s hidden property.

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:

  1. Markup: on closed panels, replace hidden with inert. A panel that keeps hidden stays display: none, so the CSS can never open it. Give each panel a class and one inner wrapper for the animation to work on.
  2. JavaScript: in the first script, swap panel.hidden = isOpen for panel.inert = isOpen. In the single-open version’s setOpen helper, swap .hidden = !open for .inert = !open.
  3. 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.

  1. 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.”
  2. 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.
  3. 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.
  4. 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.
  5. Try on reduced motion. In System Settings → Accessibility → Display, switch on Reduce motion. Items should open and close without animating.
  6. 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:

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. 🌱

Back to top