How to create an accessible accordion with JavaScript

Accordion sections let a page group related content behind short headings without making keyboard users hunt for hidden controls. An accessible accordion uses a real button for each heading, exposes whether the related panel is open, and leaves visible panel content reachable through normal keyboard navigation.

A native heading and button structure keeps the control discoverable to both browsers and assistive technology. aria-expanded belongs on the button, aria-controls points at the panel, and the hidden attribute removes collapsed panel content from rendering until it opens.

The implementation allows multiple panels to stay open, which fits FAQ, settings, and help screens where readers may compare sections. Add single-open behavior only when product requirements need it, and test the component with mouse, Enter, Space, and Tab before reusing it.

Steps to create an accessible JavaScript accordion:

  1. Add accordion headings, buttons, and panels to the HTML.
    <section class="accordion" data-accordion>
      <h2 class="accordion__heading">
        <button
          class="accordion__trigger"
          id="accordion-trigger-billing"
          type="button"
          aria-expanded="false"
          aria-controls="accordion-panel-billing"
          data-accordion-trigger>
          Billing address
        </button>
      </h2>
      <div
        class="accordion__panel"
        id="accordion-panel-billing"
        role="region"
        aria-labelledby="accordion-trigger-billing"
        hidden>
        <p>Use the same address as the payment method on file.</p>
      </div>
     
      <h2 class="accordion__heading">
        <button
          class="accordion__trigger"
          id="accordion-trigger-shipping"
          type="button"
          aria-expanded="false"
          aria-controls="accordion-panel-shipping"
          data-accordion-trigger>
          Shipping address
        </button>
      </h2>
      <div
        class="accordion__panel"
        id="accordion-panel-shipping"
        role="region"
        aria-labelledby="accordion-trigger-shipping"
        hidden>
        <p>Send this order to the default warehouse pickup address.</p>
      </div>
    </section>

    Keep each button as the only element inside its heading. The aria-controls value must match the panel id, and aria-labelledby should point back to the button when the panel uses role=“region”.

  2. Add styles for the panel and visible keyboard focus.
    .accordion {
      max-width: 42rem;
      border: 1px solid #d5dbe3;
      border-radius: 0.5rem;
    }
     
    .accordion__heading {
      margin: 0;
    }
     
    .accordion__trigger {
      display: block;
      width: 100%;
      padding: 1rem;
      border: 0;
      border-bottom: 1px solid #d5dbe3;
      background: #f6f8fb;
      color: #172033;
      font: inherit;
      font-weight: 700;
      text-align: left;
    }
     
    .accordion__trigger:focus-visible {
      outline: 3px solid #2563eb;
      outline-offset: -3px;
    }
     
    .accordion__panel {
      padding: 1rem;
      border-bottom: 1px solid #d5dbe3;
    }
     
    .accordion__panel[hidden] {
      display: none;
    }

    The explicit [hidden] rule prevents component styles from accidentally overriding the browser's hidden-state rule.

  3. Add the JavaScript toggle handler.
    const triggers = document.querySelectorAll("[data-accordion-trigger]");
     
    function setAccordionState(trigger, expanded) {
      const panelId = trigger.getAttribute("aria-controls");
      const panel = document.getElementById(panelId);
     
      if (!panel) {
        return;
      }
     
      trigger.setAttribute("aria-expanded", String(expanded));
      panel.hidden = !expanded;
    }
     
    triggers.forEach((trigger) => {
      trigger.addEventListener("click", () => {
        const isExpanded = trigger.getAttribute("aria-expanded") === "true";
        setAccordionState(trigger, !isExpanded);
      });
    });

    Native buttons fire the click handler for mouse, touch, Enter, and Space activation, so a basic accordion does not need a custom keydown handler.
    Related: How to add a JavaScript event listener

  4. Load the stylesheet and deferred script from the HTML page.
    <link rel="stylesheet" href="accordion.css">
    <script src="accordion.js" defer></script>

    Use defer for a standalone script so the accordion markup exists before the script selects the buttons.

  5. Open the page and click Billing address.
  6. Check that the opened button and panel state match.
    > document.querySelector("#accordion-trigger-billing").getAttribute("aria-expanded")
    "true"
    > document.querySelector("#accordion-panel-billing").hidden
    false
  7. Move focus to Shipping address with Tab.
  8. Press Enter to open the focused panel.
  9. Press Space to collapse the focused panel and confirm the state returned to closed.
    > document.querySelector("#accordion-trigger-shipping").getAttribute("aria-expanded")
    "false"
    > document.querySelector("#accordion-panel-shipping").hidden
    true