Skip to content

Accessibility specialists

  • About
  • Services
  • Blog
  • Resources
Email us
  1. Home
  2. Resources
  3. Accessible components library
  4. Accordion

Pages in this section

Components

  • Accordion
    • Accessibility requirements
    • Test scenarios
  • Breadcrumbs
  • Dialog
  • Disclosure
  • Pagination
  • Tabs
  • Text input
  • Textarea

More information

  • Accessibility requirements
  • Compatibility notes
  • Glossary
  • Test steps
  • View the project on GitHub

Accordion

The accordion component is a stack of related sections, each of which people can expand to reveal its content or collapse to hide it.

Each section is made up of two parts:

  • A trigger, which people can select to expand or collapse the section
  • The expandable / collapsible area, with the section content

There are two variants:

  • Default: The accordion lets each section expand and collapse independently. A "show/hide all sections" button at the top expands or collapses every section at once. If all sections are open, the button reads "hide all sections". If at least one section is closed, the button reads "show all sections".
  • One at a time: The accordion can only have one section open at any given time. All sections can be closed, but opening a closed section will automatically close any open section. The first section is open when the page loads.

Example

Open this example in a new tab
  • HTML
  • CSS
  • JavaScript
<div class="tl-accordion">
  <button type="button" class="tl-accordion__toggle-all" aria-expanded="false" data-hide-label="Hide all sections" hidden>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
      <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
    </svg>
    <span>Show all sections</span>
  </button>

  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-content-1" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      What is an accessibility audit?
    </button>

    <p>
      What is an accessibility audit?
    </p>

    <div id="accordion-content-1">
      <p>
        An audit is a detailed review of a website or application against the Web
        Content Accessibility Guidelines (WCAG). It identifies the barriers people
        encounter, and explains how to remove them.
      </p>
    </div>
  </div>

  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-content-2" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      How long does an audit take?
    </button>

    <p>
      How long does an audit take?
    </p>

    <div id="accordion-content-2">
      <p>
        It depends on the size of the product and the number of unique samples.
        Most audits take between two and four weeks from the start of testing to
        the delivery of the report.
      </p>
    </div>
  </div>

  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-content-3" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      What do we get at the end?
    </button>

    <p>
      What do we get at the end?
    </p>

    <div id="accordion-content-3">
      <p>
        You get a report which lists each issue, the people it affects, the
        guidelines it relates to, and a recommendation for how to fix it.
      </p>
    </div>
  </div>
</div>
/* -------------------------------------------------------------------------- */
/* Accordion                                                                  */
/* -------------------------------------------------------------------------- */

.tl-accordion {
  --colour-trigger-text: var(--tl-colour-text, #222222);
  --colour-trigger-background: var(--tl-colour-surface, #f0f0eb);
  --colour-trigger-background-expanded: var(--tl-colour-page, #ffffff);
  --colour-trigger-highlight-expanded: var(--tl-colour-accent, #cc0092);

  --colour-content-text: var(--tl-colour-text, #222222);
  --colour-content-background: var(--tl-colour-page, #ffffff);

  --colour-link-text: var(--tl-colour-text-link, #7f238b);

  --colour-border: var(--tl-colour-border, #666666);

  --colour-focus-highlight: var(--tl-colour-highlight-focus, #ffc000);
  --colour-focus-contrasting: var(--tl-colour-page, #ffffff);

  --border-width: 1px;
}

div.tl-accordion {
  max-inline-size: 60ch;
}

/* The trigger, the placeholder shown in its place when there is no JavaScript,
   and the Show all sections control, which heads the sections */
.tl-accordion__section > button,
.tl-accordion__section > p,
.tl-accordion__toggle-all {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  inline-size: 100%;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  margin: 0;
  font: inherit;
  color: var(--colour-trigger-text);
  text-align: start;
  background-color: var(--colour-trigger-background);
  border: var(--border-width) solid var(--colour-border);
}

.tl-accordion__section > button,
.tl-accordion__toggle-all {
  cursor: pointer;
}

.tl-accordion__section > button svg,
.tl-accordion__toggle-all svg {
  flex: none;
  max-inline-size: 24px;
  max-block-size: 24px;
}

/* The content, which the trigger expands and collapses */
.tl-accordion__section > div {
  padding: 1rem;
  color: var(--colour-content-text);
  background-color: var(--colour-content-background);
  border: var(--border-width) solid var(--colour-border);
  border-block-start: none;
}

/* While collapsed, the content is hidden until found, so the browser can still
   reveal it for find-in-page and for links to anchors inside it. The element
   keeps its box in that state, so its padding and border come off, or they
   would show as a stub under the trigger. In print every section is shown, so
   the box is left alone there */
@media screen {
  .tl-accordion__section > div[hidden] {
    padding: 0;
    border: 0;
  }
}

.tl-accordion__section + .tl-accordion__section,
.tl-accordion__toggle-all:not([hidden]) + .tl-accordion__section {
  margin-block-start: calc(-1 * var(--border-width));
}

/* State styles */
.tl-accordion__section > button[aria-expanded="true"] {
  background-color: var(--colour-trigger-background-expanded);
  border-block-start: none;
  border-block-end: none;
  box-shadow: inset 0 2px 0 0 var(--colour-trigger-highlight-expanded);
}

.tl-accordion__section > button[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

/* Forced colours drop box shadows and background colours, which takes away the
   expanded trigger's accent stripe and leaves nothing between it and the trigger
   above or the content below. Draw both as real borders instead, with a thicker
   top border standing in for the stripe */
@media (forced-colors: active) {
  .tl-accordion__section > button[aria-expanded="true"] {
    border-block-start: 3px solid currentcolor;
    border-block-end: var(--border-width) solid currentcolor;
  }
}

/* The Show all sections control reads as a header, set apart from the triggers below it */
.tl-accordion__toggle-all {
  font-weight: bold;
}

/* The icon turns once every section is open and the control will collapse them */
.tl-accordion__toggle-all[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

/* Hidden controls stay hidden, whatever display value is set above */
.tl-accordion__section > button[hidden],
.tl-accordion__toggle-all[hidden] {
  display: none;
}

/* Focus styles */
.tl-accordion__section > button:focus-visible,
.tl-accordion__toggle-all:focus-visible {
  outline: 3px solid var(--colour-focus-contrasting);
  box-shadow: inset 0 0 0 3px var(--colour-focus-highlight);
}

/* On paper there is nothing to press, so every section is shown and the Show
   all sections control is left out. The chevrons would point the wrong way
   for a section shown while collapsed, so they are left blank. The display
   value on the content is for browsers which treat hidden="until-found" as
   plain hidden */
@media print {
  .tl-accordion__section > div[hidden] {
    display: block;
    content-visibility: visible;
  }

  .tl-accordion__toggle-all {
    display: none;
  }

  .tl-accordion__section > button svg {
    visibility: hidden;
  }
}

/* Demo content */
a.demo-link {
  color: var(--colour-link-text);
}

a.demo-link:focus-visible {
  outline: 3px solid var(--colour-focus-highlight);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--colour-focus-contrasting);
}
/**
 * Accordion
 *
 * The markup works without JavaScript: every section is expanded, each trigger
 * is hidden, and a plain text placeholder stands in for it. This script removes
 * the placeholders, reveals the triggers and collapses the sections.
 *
 * By default each section opens and closes on its own, and a "Show all
 * sections" button at the top expands or collapses them all at once. The
 * .tl-accordion--one-at-a-time variant has no such button, opens its first
 * section on load, and closes the open section when another one is opened.
 *
 * Collapsed content is hidden with hidden="until-found", so the browser can
 * still reach it with find-in-page and with links to anchors inside it. When
 * it does, it reveals the content itself and fires a beforematch event first,
 * which the script uses to open the section properly. Browsers without
 * support treat the attribute as plain hidden.
 */

// The element a link to an anchor on this page points at, when the page was opened at one
const anchor = location.hash
  ? document.getElementById(decodeURIComponent(location.hash.slice(1)))
  : null;

function setExpanded(section, expanded) {
  section.trigger.setAttribute("aria-expanded", expanded ? "true" : "false");

  if (expanded) {
    section.content.removeAttribute("hidden");
  } else {
    section.content.setAttribute("hidden", "until-found");
  }
}

function isExpanded(section) {
  return section.trigger.getAttribute("aria-expanded") === "true";
}

// Wire up the "Show all sections" button, and return a function which keeps it
// in step with the sections. The visible text in the markup is the collapsed
// label, so it is only written once. The expanded label comes from
// data-hide-label. Both are whole phrases rather than a fixed verb and a noun,
// because word order differs between languages
function setUpToggleAll(toggleAll, sections) {
  // The label sits in a span so that the icon is left alone
  const label = toggleAll.querySelector(":scope > span") || toggleAll;
  const showLabel = label.textContent.trim();
  const hideLabel = toggleAll.dataset.hideLabel || "Hide all sections";

  // The button carries an aria-expanded state like the triggers, so screen
  // readers announce the change when it is pressed. A label change on its own
  // is not announced by VoiceOver with Safari. The state and the label only say
  // every section is open once that is true, so the two never disagree
  function update() {
    const expanded = sections.every(isExpanded);
    toggleAll.setAttribute("aria-expanded", expanded ? "true" : "false");
    label.textContent = expanded ? hideLabel : showLabel;
  }

  toggleAll.setAttribute(
    "aria-controls",
    sections
      .map((section) => section.content.id)
      .filter(Boolean)
      .join(" "),
  );

  // Expand every section unless they are all open already, in which case
  // collapse them all
  toggleAll.addEventListener("click", () => {
    const expand = !sections.every(isExpanded);
    sections.forEach((section) => setExpanded(section, expand));
    update();
  });

  update();
  toggleAll.hidden = false;
  return update;
}

document.querySelectorAll(".tl-accordion").forEach((accordion) => {
  const oneAtATime = accordion.classList.contains(
    "tl-accordion--one-at-a-time",
  );
  const sections = [];

  for (const element of accordion.querySelectorAll(
    ":scope > .tl-accordion__section",
  )) {
    const trigger = element.querySelector(":scope > button");
    const placeholder = element.querySelector(":scope > p");
    const content = element.querySelector(":scope > div");

    // There is nothing to toggle without both a trigger and some content
    if (!trigger || !content) continue;

    if (placeholder) placeholder.remove();
    trigger.hidden = false;
    sections.push({ trigger, content });
  }

  if (!sections.length) return;

  // Every section starts collapsed, apart from the one holding the anchor the
  // page was opened at, or failing that the first section of a one-at-a-time
  // accordion, which opens the way the first tab of a tab set does
  const opened = sections.find((section) => section.content.contains(anchor));
  sections.forEach((section, index) =>
    setExpanded(
      section,
      opened ? section === opened : oneAtATime && index === 0,
    ),
  );

  // The one-at-a-time variant has no "Show all sections" button
  const toggleAll = oneAtATime
    ? null
    : accordion.querySelector(":scope > .tl-accordion__toggle-all");
  const updateToggleAll = toggleAll
    ? setUpToggleAll(toggleAll, sections)
    : () => {};

  // Opens or closes a section. Only one section stays open in the one-at-a-time
  // variant, so the others are closed first
  function toggle(section, expanded) {
    if (oneAtATime) {
      sections.forEach((other) => setExpanded(other, false));
    }

    setExpanded(section, expanded);
    updateToggleAll();
  }

  sections.forEach((section) => {
    section.trigger.addEventListener("click", () =>
      toggle(section, !isExpanded(section)),
    );

    // The browser reveals the content itself when find-in-page or a link to an
    // anchor inside it finds a match, and says so first
    section.content.addEventListener("beforematch", () =>
      toggle(section, true),
    );
  });
});
  • HTML
  • CSS
  • JavaScript
<div class="tl-accordion tl-accordion--one-at-a-time">
  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-one-at-a-time-content-1" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      Auditing
    </button>

    <p>
      Auditing
    </p>

    <div id="accordion-one-at-a-time-content-1">
      <p>
        We review a website or application against the Web Content Accessibility
        Guidelines, and report the barriers people encounter along with a
        recommendation for each one. Read more on the
        <a class="demo-link" href="https://tetralogical.com">TetraLogical website</a>.
      </p>
    </div>
  </div>

  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-one-at-a-time-content-2" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      Training
    </button>

    <p>
      Training
    </p>

    <div id="accordion-one-at-a-time-content-2">
      <p>
        We run workshops for designers, developers, content authors and testers,
        so that teams can find and fix accessibility problems themselves rather
        than waiting for the next audit.
      </p>
    </div>
  </div>

  <div class="tl-accordion__section">
    <button type="button" aria-expanded="true" aria-controls="accordion-one-at-a-time-content-3" hidden>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" />
      </svg>
      Research
    </button>

    <p>
      Research
    </p>

    <div id="accordion-one-at-a-time-content-3">
      <p>
        We carry out usability research with disabled people, so that teams
        understand how their product performs in practice and not only whether
        it meets the guidelines.
      </p>
    </div>
  </div>
</div>
/* -------------------------------------------------------------------------- */
/* Accordion                                                                  */
/* -------------------------------------------------------------------------- */

.tl-accordion {
  --colour-trigger-text: var(--tl-colour-text, #222222);
  --colour-trigger-background: var(--tl-colour-surface, #f0f0eb);
  --colour-trigger-background-expanded: var(--tl-colour-page, #ffffff);
  --colour-trigger-highlight-expanded: var(--tl-colour-accent, #cc0092);

  --colour-content-text: var(--tl-colour-text, #222222);
  --colour-content-background: var(--tl-colour-page, #ffffff);

  --colour-link-text: var(--tl-colour-text-link, #7f238b);

  --colour-border: var(--tl-colour-border, #666666);

  --colour-focus-highlight: var(--tl-colour-highlight-focus, #ffc000);
  --colour-focus-contrasting: var(--tl-colour-page, #ffffff);

  --border-width: 1px;
}

div.tl-accordion {
  max-inline-size: 60ch;
}

/* The trigger, the placeholder shown in its place when there is no JavaScript,
   and the Show all sections control, which heads the sections */
.tl-accordion__section > button,
.tl-accordion__section > p,
.tl-accordion__toggle-all {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  inline-size: 100%;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  margin: 0;
  font: inherit;
  color: var(--colour-trigger-text);
  text-align: start;
  background-color: var(--colour-trigger-background);
  border: var(--border-width) solid var(--colour-border);
}

.tl-accordion__section > button,
.tl-accordion__toggle-all {
  cursor: pointer;
}

.tl-accordion__section > button svg,
.tl-accordion__toggle-all svg {
  flex: none;
  max-inline-size: 24px;
  max-block-size: 24px;
}

/* The content, which the trigger expands and collapses */
.tl-accordion__section > div {
  padding: 1rem;
  color: var(--colour-content-text);
  background-color: var(--colour-content-background);
  border: var(--border-width) solid var(--colour-border);
  border-block-start: none;
}

/* While collapsed, the content is hidden until found, so the browser can still
   reveal it for find-in-page and for links to anchors inside it. The element
   keeps its box in that state, so its padding and border come off, or they
   would show as a stub under the trigger. In print every section is shown, so
   the box is left alone there */
@media screen {
  .tl-accordion__section > div[hidden] {
    padding: 0;
    border: 0;
  }
}

.tl-accordion__section + .tl-accordion__section,
.tl-accordion__toggle-all:not([hidden]) + .tl-accordion__section {
  margin-block-start: calc(-1 * var(--border-width));
}

/* State styles */
.tl-accordion__section > button[aria-expanded="true"] {
  background-color: var(--colour-trigger-background-expanded);
  border-block-start: none;
  border-block-end: none;
  box-shadow: inset 0 2px 0 0 var(--colour-trigger-highlight-expanded);
}

.tl-accordion__section > button[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

/* Forced colours drop box shadows and background colours, which takes away the
   expanded trigger's accent stripe and leaves nothing between it and the trigger
   above or the content below. Draw both as real borders instead, with a thicker
   top border standing in for the stripe */
@media (forced-colors: active) {
  .tl-accordion__section > button[aria-expanded="true"] {
    border-block-start: 3px solid currentcolor;
    border-block-end: var(--border-width) solid currentcolor;
  }
}

/* The Show all sections control reads as a header, set apart from the triggers below it */
.tl-accordion__toggle-all {
  font-weight: bold;
}

/* The icon turns once every section is open and the control will collapse them */
.tl-accordion__toggle-all[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

/* Hidden controls stay hidden, whatever display value is set above */
.tl-accordion__section > button[hidden],
.tl-accordion__toggle-all[hidden] {
  display: none;
}

/* Focus styles */
.tl-accordion__section > button:focus-visible,
.tl-accordion__toggle-all:focus-visible {
  outline: 3px solid var(--colour-focus-contrasting);
  box-shadow: inset 0 0 0 3px var(--colour-focus-highlight);
}

/* On paper there is nothing to press, so every section is shown and the Show
   all sections control is left out. The chevrons would point the wrong way
   for a section shown while collapsed, so they are left blank. The display
   value on the content is for browsers which treat hidden="until-found" as
   plain hidden */
@media print {
  .tl-accordion__section > div[hidden] {
    display: block;
    content-visibility: visible;
  }

  .tl-accordion__toggle-all {
    display: none;
  }

  .tl-accordion__section > button svg {
    visibility: hidden;
  }
}

/* Demo content */
a.demo-link {
  color: var(--colour-link-text);
}

a.demo-link:focus-visible {
  outline: 3px solid var(--colour-focus-highlight);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--colour-focus-contrasting);
}
/**
 * Accordion
 *
 * The markup works without JavaScript: every section is expanded, each trigger
 * is hidden, and a plain text placeholder stands in for it. This script removes
 * the placeholders, reveals the triggers and collapses the sections.
 *
 * By default each section opens and closes on its own, and a "Show all
 * sections" button at the top expands or collapses them all at once. The
 * .tl-accordion--one-at-a-time variant has no such button, opens its first
 * section on load, and closes the open section when another one is opened.
 *
 * Collapsed content is hidden with hidden="until-found", so the browser can
 * still reach it with find-in-page and with links to anchors inside it. When
 * it does, it reveals the content itself and fires a beforematch event first,
 * which the script uses to open the section properly. Browsers without
 * support treat the attribute as plain hidden.
 */

// The element a link to an anchor on this page points at, when the page was opened at one
const anchor = location.hash
  ? document.getElementById(decodeURIComponent(location.hash.slice(1)))
  : null;

function setExpanded(section, expanded) {
  section.trigger.setAttribute("aria-expanded", expanded ? "true" : "false");

  if (expanded) {
    section.content.removeAttribute("hidden");
  } else {
    section.content.setAttribute("hidden", "until-found");
  }
}

function isExpanded(section) {
  return section.trigger.getAttribute("aria-expanded") === "true";
}

// Wire up the "Show all sections" button, and return a function which keeps it
// in step with the sections. The visible text in the markup is the collapsed
// label, so it is only written once. The expanded label comes from
// data-hide-label. Both are whole phrases rather than a fixed verb and a noun,
// because word order differs between languages
function setUpToggleAll(toggleAll, sections) {
  // The label sits in a span so that the icon is left alone
  const label = toggleAll.querySelector(":scope > span") || toggleAll;
  const showLabel = label.textContent.trim();
  const hideLabel = toggleAll.dataset.hideLabel || "Hide all sections";

  // The button carries an aria-expanded state like the triggers, so screen
  // readers announce the change when it is pressed. A label change on its own
  // is not announced by VoiceOver with Safari. The state and the label only say
  // every section is open once that is true, so the two never disagree
  function update() {
    const expanded = sections.every(isExpanded);
    toggleAll.setAttribute("aria-expanded", expanded ? "true" : "false");
    label.textContent = expanded ? hideLabel : showLabel;
  }

  toggleAll.setAttribute(
    "aria-controls",
    sections
      .map((section) => section.content.id)
      .filter(Boolean)
      .join(" "),
  );

  // Expand every section unless they are all open already, in which case
  // collapse them all
  toggleAll.addEventListener("click", () => {
    const expand = !sections.every(isExpanded);
    sections.forEach((section) => setExpanded(section, expand));
    update();
  });

  update();
  toggleAll.hidden = false;
  return update;
}

document.querySelectorAll(".tl-accordion").forEach((accordion) => {
  const oneAtATime = accordion.classList.contains(
    "tl-accordion--one-at-a-time",
  );
  const sections = [];

  for (const element of accordion.querySelectorAll(
    ":scope > .tl-accordion__section",
  )) {
    const trigger = element.querySelector(":scope > button");
    const placeholder = element.querySelector(":scope > p");
    const content = element.querySelector(":scope > div");

    // There is nothing to toggle without both a trigger and some content
    if (!trigger || !content) continue;

    if (placeholder) placeholder.remove();
    trigger.hidden = false;
    sections.push({ trigger, content });
  }

  if (!sections.length) return;

  // Every section starts collapsed, apart from the one holding the anchor the
  // page was opened at, or failing that the first section of a one-at-a-time
  // accordion, which opens the way the first tab of a tab set does
  const opened = sections.find((section) => section.content.contains(anchor));
  sections.forEach((section, index) =>
    setExpanded(
      section,
      opened ? section === opened : oneAtATime && index === 0,
    ),
  );

  // The one-at-a-time variant has no "Show all sections" button
  const toggleAll = oneAtATime
    ? null
    : accordion.querySelector(":scope > .tl-accordion__toggle-all");
  const updateToggleAll = toggleAll
    ? setUpToggleAll(toggleAll, sections)
    : () => {};

  // Opens or closes a section. Only one section stays open in the one-at-a-time
  // variant, so the others are closed first
  function toggle(section, expanded) {
    if (oneAtATime) {
      sections.forEach((other) => setExpanded(other, false));
    }

    setExpanded(section, expanded);
    updateToggleAll();
  }

  sections.forEach((section) => {
    section.trigger.addEventListener("click", () =>
      toggle(section, !isExpanded(section)),
    );

    // The browser reveals the content itself when find-in-page or a link to an
    // anchor inside it finds a match, and says so first
    section.content.addEventListener("beforematch", () =>
      toggle(section, true),
    );
  });
});

What problems it solves

The accordion component helps reduce clutter on pages with a lot of related content. People can scan the section labels to find what they need, and expand only the sections that are relevant to them.

When to use it

Use the accordion component to reduce cognitive load when you have a lot of related content that people don't need to see all at once.

Use the default accordion when people may want to read or compare several sections, such as a list of frequently asked questions.

Use the one at a time variant when people are likely to only read one section at a time. This is similar to a tabs component.

Prefer the default accordion unless you have a reason not to. In the one at a time variant, collapsing a section that somebody did not ask you to collapse takes content away without warning, and it prevents people from being able to compare two sections side by side.

When not to use it

Do not use the accordion component for important information as some people may miss it.

Do not use it for a single section of content. Use the disclosure component instead.

Do not use it for content that most people need to read in full. Hiding it makes people do more work to reach it.

Where to use it

Accordions are often found in FAQs, product details, help pages, and long forms split into steps.

How it works

Each trigger is a button which describes its expanded or collapsed state.

The buttons and sections receive focus in the order they appear in the markup, and keyboard focus stays on the button after it is activated.

The default accordion has a "show all sections" button, which expands every section. It carries its own aria-expanded state like the rest of the buttons, but its label also changes to "Hide all sections". Arguably, because the label changes, it should not need to also use aria-expanded, but due to a VoiceOver bug, if you do not include it, then the label change is not always announced.

To make sure the state and the label never disagree, if some sections are open and others are closed, the button still reads "show all sections" with a collapsed state. Selecting it opens everything, changes the label to "hide all sections", and changes the state on the "show/hide all sections" button to expanded.

The button's collapsed label is set in the markup, and its expanded label is set using the data-hide-label attribute. Each is a whole phrase rather than a hard-coded "show/hide" prefix to make it compatible with localisation, because word order can differ between languages. If a page has more than one accordion, make each label specific, such as "show all frequently asked questions", so there is no confusion as to what each control does.

The one at a time variant has no "show all sections" button, because only one section can be open at a time. Activating the open section collapses it, which leaves every section closed.

Collapsed content is hidden using hidden="until-found", so anything focusable inside a collapsed section still revealed when using the browsers in-page search. Older browsers treat the attribute as plain hidden, so the content stays hidden until the button is used. See Hidden versus hidden until found in the compatibility notes for more information.

The content inside the collapsed section has an id, which can be targeted using a fragment in the URL. If the fragment is present on the URL, the disclosure will be open, so people can see the shared content when they arrive on the page.

When the page is printed, every section is shown expanded and the Show all sections button is left out.

Without JavaScript

Every section is progressively enhanced in the markup, so its content remains readable if the script does not run for some reason. Each trigger button is hidden on page load, but sits alongside a plain text placeholder with the same label. When the script runs, it collapses the content and swaps out the placeholder text for the buttons. This means all the content is available and no buttons are shown until it's all wired up to work.

Assistive technology compatibility

This component has been tested with the following browsers and assistive technologies.

Assistive technology Result Notes
Keyboard No issues No notes
Voice Access/Chrome 154.0.8/Windows 11 No issues No notes
JAWS 2026.2603.86/Chrome 154.0.8/Windows 11 No issues No notes
NVDA 2026.1.1/Firefox 157.0/Windows 11 No issues No notes
VoiceOver/Safari macOS Tahoe 26.6 No issues No notes
VoiceOver/Safari/iOS 26.0 No issues Does not announce expanded or collapsed immediately after state change. This is a known bug with WebKit. See aria-expanded states are not announced immediately following a change on iOS in the compatibility notes.
TalkBack/Chrome 154.0.8/Android 17 No issues No notes
Windows Contrast themes No issues No notes
Windows Magnifier and/or Apple Zoom No issues No notes
Browser zoom No issues No notes
prefers-reduced-motion Not applicable There are no animations to reduce

Last updated: 13 October 2026.

Back to top

We like to listen

Wherever you are in your accessibility journey, get in touch if you have a project or idea.

hello@tetralogical.com
Colour scheme

60 Windsor Avenue, London, SW19 2RR, UK +44 (0)20 8895 6768 hello@tetralogical.com

© TetraLogical Services Ltd. All Rights Reserved. Company number 12951928.

Privacy policy • Accessibility statement • Code of ethics