Accessible JavaScript Accordion and Tabs

An accessible accordion uses a real button inside each heading, with aria-expanded showing whether its panel is open and aria-controls naming the panel. Accessible tabs use role=tablist, tab and tabpanel, with aria-selected, and move between tabs with the arrow keys, keeping only the selected tab in the Tab order. Both demos below run live.

Which one to use
UseWhen
AccordionLong pages of questions, where people may read several answers
TabsA few views of the same thing, read one at a time
details and summarySimple show and hide with no JavaScript at all

Updated

Each demo is one self-contained block of HTML, CSS and JavaScript. Edit it and the result re-runs. Then put your mouse away and test it with the keyboard using the checklist further down.

Working in React? Start from our React tabs components.

Accordion

Accessible accordion with animated height

Each heading holds a real button with aria-expanded and aria-controls. The height animates with a grid-rows transition, so there is no measuring of scrollHeight. Try it with Tab, Enter and Space.

<div class="acc">
  <h3><button type="button" aria-expanded="false" aria-controls="p1" id="b1">Do you deliver?</button></h3>
  <div class="panel" id="p1" role="region" aria-labelledby="b1" hidden>
    <div><p>Yes, within 5 km of the shop, every day except Sunday.</p></div>
  </div>
  <h3><button type="button" aria-expanded="false" aria-controls="p2" id="b2">Can I change my order?</button></h3>
  <div class="panel" id="p2" role="region" aria-labelledby="b2" hidden>
    <div><p>Up to two hours before the delivery slot, from the link in your email.</p></div>
  </div>
  <h3><button type="button" aria-expanded="false" aria-controls="p3" id="b3">Do you have gift cards?</button></h3>
  <div class="panel" id="p3" role="region" aria-labelledby="b3" hidden>
    <div><p>Yes, from $10 to $200, sent by email straight away.</p></div>
  </div>
</div>

<style>
  .acc h3 { margin: 0; border-bottom: 1px solid #e5e5e5; }
  .acc button { all: unset; box-sizing: border-box; width: 100%; padding: 12px 4px; cursor: pointer;
                display: flex; justify-content: space-between; font-weight: 600; }
  .acc button::after { content: "+"; transition: transform .25s; }
  .acc button[aria-expanded="true"]::after { transform: rotate(45deg); }
  .acc button:focus-visible { outline: 2px solid #4f98fb; outline-offset: 2px; }
  /* Height animation: grid rows go from 0fr to 1fr, no measuring needed. */
  .panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .25s ease; }
  .panel.open { grid-template-rows: 1fr; }
  .panel[hidden] { display: none; }
  .panel > div { overflow: hidden; }
  .panel p { margin: 0 4px 12px; color: #3a3a3a; }
  @media (prefers-reduced-motion: reduce) { .panel, .acc button::after { transition: none; } }
</style>

<script>
document.querySelectorAll(".acc button[aria-controls]").forEach((btn) => {
  const panel = document.getElementById(btn.getAttribute("aria-controls"));
  btn.addEventListener("click", () => {
    const open = btn.getAttribute("aria-expanded") === "true";
    btn.setAttribute("aria-expanded", String(!open));
    if (open) {
      panel.classList.remove("open");
      panel.addEventListener("transitionend", () => { panel.hidden = true; }, { once: true });
      if (matchMedia("(prefers-reduced-motion: reduce)").matches) panel.hidden = true;
    } else {
      panel.hidden = false;
      requestAnimationFrame(() => panel.classList.add("open"));
    }
  });
});
</script>

Only one panel open at a time

When a panel opens, close any other open one. Keep this optional: people comparing two answers often want both open.

<script>
// Variation: only one panel open at a time. Add this after the accordion script.
document.querySelectorAll(".acc button[aria-controls]").forEach((btn) => {
  btn.addEventListener("click", () => {
    if (btn.getAttribute("aria-expanded") !== "true") return;
    document.querySelectorAll(".acc button[aria-expanded='true']").forEach((other) => {
      if (other !== btn) other.click();
    });
  });
});
</script>

No-JavaScript fallback: details and summary

Native details elements open and close with no script. Giving them the same name attribute makes them exclusive in current browsers; older ones just allow several open.

<details name="faq">
  <summary>Do you deliver?</summary>
  <p>Yes, within 5 km of the shop.</p>
</details>
<details name="faq">
  <summary>Do you have gift cards?</summary>
  <p>Yes, sent by email straight away.</p>
</details>

Tabs

Tabs with roving tabindex and arrow keys

Only the selected tab has tabindex 0, so Tab moves into the tab list once and then on to the panel. Left and Right arrows move between tabs, Home and End jump to the ends.

<div class="tabs">
  <div role="tablist" aria-label="Opening hours">
    <button role="tab" id="t1" aria-selected="true" aria-controls="tp1" tabindex="0">Weekdays</button>
    <button role="tab" id="t2" aria-selected="false" aria-controls="tp2" tabindex="-1">Saturday</button>
    <button role="tab" id="t3" aria-selected="false" aria-controls="tp3" tabindex="-1">Sunday</button>
  </div>
  <div role="tabpanel" id="tp1" aria-labelledby="t1" tabindex="0"><p>8 am to 6 pm</p></div>
  <div role="tabpanel" id="tp2" aria-labelledby="t2" tabindex="0" hidden><p>9 am to 4 pm</p></div>
  <div role="tabpanel" id="tp3" aria-labelledby="t3" tabindex="0" hidden><p>Closed</p></div>
</div>

<style>
  [role="tablist"] { display: flex; gap: 4px; border-bottom: 1px solid #e5e5e5; }
  [role="tab"] { all: unset; padding: 8px 14px; cursor: pointer; border-bottom: 2px solid transparent; }
  [role="tab"][aria-selected="true"] { border-color: #4f98fb; font-weight: 600; }
  [role="tab"]:focus-visible, [role="tabpanel"]:focus-visible { outline: 2px solid #4f98fb; outline-offset: 2px; }
  [role="tabpanel"] p { margin: 12px 4px; }
</style>

<script>
const tabs = [...document.querySelectorAll('[role="tab"]')];
function select(tab, focus = true) {
  tabs.forEach((t) => {
    const on = t === tab;
    t.setAttribute("aria-selected", String(on));
    t.tabIndex = on ? 0 : -1;               // roving tabindex: only the active tab is in the Tab order
    document.getElementById(t.getAttribute("aria-controls")).hidden = !on;
  });
  if (focus) tab.focus();
}
tabs.forEach((tab, i) => {
  tab.addEventListener("click", () => select(tab));
  tab.addEventListener("keydown", (e) => {
    const n = tabs.length;
    const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    select(tabs[next]);                     // automatic activation: arrows also switch the panel
  });
});
</script>

Keyboard and ARIA checklist

Keyboard test: try each one in the demos
KeyAccordionTabs
TabMoves to the next header buttonEnters the tab list on the selected tab, then moves to the panel
Shift + TabMoves to the previous header buttonMoves back out of the tab list
Enter or SpaceOpens or closes the focused panelSelects the focused tab (needed only with manual activation)
Right / Left arrowNot requiredNext / previous tab, wrapping round
Home / EndOptional: first / last headerFirst / last tab

ARIA reference

Attributes each part needs
ElementAccordionTabs
Triggerbutton inside a heading, aria-expanded, aria-controlsrole=tab, aria-selected, aria-controls, tabindex 0 or -1
ContainerNone requiredrole=tablist with aria-label
Panelid; role=region with aria-labelledby is optionalrole=tabpanel, aria-labelledby, tabindex 0 if it has no focusable content
Hidden statehidden attribute on closed panelshidden attribute on inactive panels

Where to go next

If you work in React, our component gallery has a tabs set with a sliding underline and a FAQ accordion, both with copyable JSX. For a FAQ section, add matching FAQPage structured data only if the questions and answers are visible on the page.

Common mistakes

Using div elements with click handlers as accordion headers. They cannot be reached with Tab and screen readers do not know they do anything.

Putting every tab in the Tab order, which forces keyboard users through all of them to reach the content.

Forgetting to update aria-expanded or aria-selected when the state changes, so the screen reader reports the wrong thing.

How it works

  1. Copy the block for the component you need into your page.
  2. Change the text, keeping each id unique on the page.
  3. Test with the keyboard: Tab, Enter, Space, the arrows, Home and End.
  4. Test with a screen reader: it should say expanded or collapsed, or tab 2 of 3.
  5. Check the page with reduced motion turned on; the panels should open without animating.

Tips

Frequently asked questions

Should tabs switch on arrow key or on Enter?

The ARIA guide allows both. Switching as the arrow moves (automatic activation) feels quickest when panels are already loaded. If showing a panel is slow, move focus with the arrows and switch on Enter or Space instead.

How do I animate an accordion's height without JavaScript measuring?

Make the panel a grid and transition grid-template-rows from 0fr to 1fr, with an inner element that has overflow hidden. The browser works out the height for you.

Is a details element accessible?

Yes. Browsers expose summary as a button with expanded state, and it works with the keyboard out of the box. Styling the marker and animating it is harder, which is why many sites still build their own.

Do hidden accordion answers count for SEO?

Content that is in the HTML but collapsed is still crawled. Keep it in the page source rather than loading it on click, and make sure the visible answer matches what is in the HTML.

Related: React tabs components, React FAQ accordion, Framer Motion examples, Schema markup generator, All free tools.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap