Build an Accessible Accordion

Are you in the right place? If you are constrained to a third-party component library, see the Accordion Accessibility Checklist and ensure that it passes. Otherwise, follow the guidance here to choose the best option for your project.


Alternatives

For projects not ready to adopt the Design System Whether you seek to remediate an existing component or build one from scratch, these reference implementations will ensure an accessible outcome. For more details, read about this pattern.

Style the native HTML details element

When to use If your team has source-level control over UI code, wants to avoid error-prone semantics programming, and has no hard requirements in excess of what native HTML provides, then the details element is a great option that has built-in accessibility and is effectively unbreakable.

Rely on the required checklists to ensure accessibility is maintained during integration.
Native example Lorem ipsum dolor sit amet.
Copy Code
<details class="nysa11y accordion" data-component="accordion" data-html="native">
    <summary>
        Native example
    </summary>
    Lorem ipsum dolor sit amet.
</details>
Copy Code
/**
 * NYSA11y Accordion (native)
 * <details><summary>
 */
@layer nysa11y {
    body:has(.nysa11y) {
        details.nysa11y.accordion[data-component="accordion"][data-html="native"] {
            --nysa11y-chevron-rotation-time: 0.3s;
            *,
            *:before,
            *:after {
                box-sizing: border-box;
            }
            font-family:
                "Proxima Nova",
                -apple-system,
                "BlinkMacSystemFont",
                "Segoe UI",
                "Roboto",
                "Helvetica",
                "Arial",
                sans-serif,
                "Apple Color Emoji",
                "Segoe UI Emoji",
                "Segoe UI Symbol";
            & summary {
                background-color: var(--nys-color-neutral-50, #ededed);
                border-radius: 0.25rem;
                color: var(--nys-color-ink, #1b1b1b);
                display: flex;
                font-size: var(--nys-type-size-ui-xl, 20px);
                font-weight: var(--nys-font-weight-bold, 700);
                gap: 0.5rem;
                line-height: 1.5rem;
                list-style-type: none;
                padding-block: 1rem;
                padding-inline: 1rem;
                word-break: break-word;
                &::before {
                    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline fill='%231b1b1b' points='14.724 12.486, 7.375 19.835, 7.013 20.710, 7.400 21.585, 8.275 21.960, 9.150 21.585, 16.849 13.911, 17.298 13.236, 17.448 12.486, 17.298 11.737, 16.849 11.062, 9.150 3.363, 8.262 3.000, 7.375 3.388, 7.000 4.263, 7.375 5.138, 14.724 12.486'/%3E%3C/svg%3E%0A");
                    background-size: contain;
                    content: "";
                    display: inline-block;
                    flex-shrink: 0;
                    height: 1.5rem;
                    width: 1.5rem;
                    transform: rotate(0deg);
                    transition: transform var(--nysa11y-chevron-rotation-time);
                }
                &:focus-visible {
                    outline: solid var(--nys-border-width-md, 2px)
                        var(--nys-color-focus, #004dd1);
                    outline-offset: var(--nys-space-2px, 2px);
                }
                &:hover,
                &:focus {
                    background-color: var(
                        --nys-accordion-background-color--header--hover,
                        var(--nys-color-neutral-100, #d0d0ce)
                    );
                }
                &::-webkit-details-marker,
                &::marker {
                    content: "";
                    display: none;
                }
            }
            &::details-content {
                opacity: 0;
                transition: opacity var(--nysa11y-chevron-rotation-time)
                    ease-in-out;
            }
            &[open] {
                summary {
                    border-end-end-radius: 0;
                    border-end-start-radius: 0;
                    &::before {
                        transform: rotate(90deg);
                    }
                }
                &::details-content {
                    background-color: var(--nys-color-ink-reverse, #ffffff);
                    opacity: 1;
                    padding-block: 1rem;
                    padding-inline: 1.25rem;
                }
            }
        }
    }
}

Build a custom accordion component

When to use If your team has source-level control over UI code, but DOES have a hard requirement not provided by native HTML, you may choose to build a custom accordion. Or perhaps your legacy component has been flagged for remediation and you need a high quality reference. This custom example recreates the accessibility built into the NYSDS Accordion and the details element. See the W3C APG Accordion Pattern for more involved implementations.

Rely on the required checklists to ensure accessibility is maintained during integration.
Recreation of semantics carries a burden of responsibility. This example is as simple as possible. See the W3C Accordion Pattern for more involved implementations.
Copy Code
<div class="nysa11y accordion" data-component="accordion" data-html="custom">
    <button data-part="summary" aria-expanded="false">
        Custom example
    </button>
    <div data-part="content">
        Recreation of semantics carries a burden of responsibility.
        This example is as simple as possible.
        See the <a href="https://www.w3.org/WAI/ARIA/apg/patterns/accordion/" target="_blank">W3C Accordion Pattern</a>
        for more involved implementations.
    </div>
</div>
Copy Code
/**
 * NYSA11y Accordion (custom)
 * Path: /assets/nysa11y/accordion-custom.css
 * Depends on /assets/nysa11y/accordion-custom.js
 */
@layer nysa11y {
    body:has(.nysa11y) {
        div.nysa11y.accordion[data-component="accordion"][data-html="custom"] {
            --nysa11y-chevron-rotation-time: 0.3s;
            *,
            *:before,
            *:after {
                box-sizing: border-box;
            }
            font-family:
                "Proxima Nova",
                -apple-system,
                "BlinkMacSystemFont",
                "Segoe UI",
                "Roboto",
                "Helvetica",
                "Arial",
                sans-serif,
                "Apple Color Emoji",
                "Segoe UI Emoji",
                "Segoe UI Symbol";
            & button[data-part="summary"] {
                background-color: var(--nys-color-neutral-50, #ededed);
                border: none;
                border-radius: 0.25rem;
                color: var(--nys-color-ink, #1b1b1b);
                display: flex;
                font-family: inherit;
                font-size: var(--nys-type-size-ui-xl, 20px);
                font-weight: var(--nys-font-weight-bold, 700);
                gap: 0.5rem;
                line-height: 1.5rem;
                list-style-type: none;
                padding-block: 1rem;
                padding-inline: 1rem;
                width: 100%;
                word-break: break-word;
                &::before {
                    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline fill='%231b1b1b' points='14.724 12.486, 7.375 19.835, 7.013 20.710, 7.400 21.585, 8.275 21.960, 9.150 21.585, 16.849 13.911, 17.298 13.236, 17.448 12.486, 17.298 11.737, 16.849 11.062, 9.150 3.363, 8.262 3.000, 7.375 3.388, 7.000 4.263, 7.375 5.138, 14.724 12.486'/%3E%3C/svg%3E%0A");
                    background-size: contain;
                    content: "";
                    display: inline-block;
                    flex-shrink: 0;
                    height: 1.5rem;
                    width: 1.5rem;
                    transform: rotate(0deg);
                    transition: transform var(--nysa11y-chevron-rotation-time);
                }
                &:focus-visible {
                    outline: solid var(--nys-border-width-md, 2px)
                        var(--nys-color-focus, #004dd1);
                    outline-offset: var(--nys-space-2px, 2px);
                }
                &:hover,
                &:focus {
                    background-color: var(
                        --nys-accordion-background-color--header--hover,
                        var(--nys-color-neutral-100, #d0d0ce)
                    );
                }
            }
            & div[data-part="content"] {
                height: 0;
                opacity: 0;
                transition: opacity var(--nysa11y-chevron-rotation-time)
                    ease-in-out;
                visibility: hidden;
            }
            &.is-open {
                button[data-part="summary"] {
                    border-end-end-radius: 0;
                    border-end-start-radius: 0;
                    &::before {
                        transform: rotate(90deg);
                    }
                }
                div[data-part="content"] {
                    background-color: var(--nys-color-ink-reverse, #ffffff);
                    height: auto;
                    opacity: 1;
                    padding-block: 1rem;
                    padding-inline: 1.25rem;
                    visibility: visible;
                }
            }
        }
    }
}
Copy Code
/**
 * NYSA11y Accordion (custom)
 * Path: /assets/nysa11y/accordion-custom.js
 * Depends on /assets/nysa11y/accordion-custom.css
 */
const nysa11y = window.nysa11y || {};

class Accordion {
  #selectorToggle =
    '[data-component="accordion"].nysa11y [data-part="summary"]';
  #selectorParent = '[data-component="accordion"].nysa11y';
  #stateOpen = "is-open";

  constructor(options = {}) {
    this.container = options.container || document;
    this.init();
  }

  init() {
    this.toggles = this.container.querySelectorAll(this.#selectorToggle);
    if (!this.toggles.length) return;

    this.toggles.forEach((toggle) => {
      // Avoid binding the same event listener multiple times
      toggle.removeEventListener("click", this.#handleToggle);
      toggle.addEventListener("click", this.#handleToggle);

      // Set initial accessibility state
      const isExpanded = toggle
        .closest(this.#selectorParent)
        ?.classList.contains(this.#stateOpen);
      toggle.setAttribute("aria-expanded", isExpanded || false);
    });
  }

  #handleToggle = (event) => {
    const toggleElement = event.currentTarget;
    const parent = toggleElement.closest(this.#selectorParent);
    if (!parent) return;

    const isOpen = parent.classList.toggle(this.#stateOpen);
    toggleElement.setAttribute("aria-expanded", isOpen);
  };
}

nysa11y.Accordion = Accordion;

document.addEventListener("DOMContentLoaded", () => {
  new nysa11y.Accordion();
});

About this pattern

An accordion is a component that presents a binary state button and an adjacent content area. The button toggles the visibility of the content. This component is also known as a "disclosure" or "expander." The native HTML analog is the details element.

The Header The accordion button is commonly called the "header," since it functions as a descriptive heading for the content. The native analog is the summary element. Because it is activated to use the component, the header MUST be focusable, and its visible label MUST either be the accessible name or be part of the accessible name. Note that the details element automagically gains its accessible name from the text of its summary.

The entire header should function semantically as a button. As such it MUST meet all rules for button accessibility. Because it is an interactive element, the header MUST NOT contain interactive child elements, otherwise it is a nested-interactive WCAG failure.

The binary state (open/expanded vs. closed/collapsed) is inherited "for free" when using details and summary. In a custom component it is recreated with aria-expanded and JavaScript.

The Content When content is hidden, it MUST be fully hidden from assistive technologies, not merely visually hidden. The details element properly hides content by default. Note that the native content area is selected in CSS with the ::details-content pseudo element.

Best Practices When multiple accordions are composed into a group, some implementations allow mutually exclusive activation, meaning that only one may be open at a time. Many Accessibility professionals consider this to be an anti-pattern. Consider, for example, the use case of comparing content between two accordions. If mutually exclusive behavior is provided, please consider giving the user an option to disable it.

NYS Digital Accessibility MS Teams channel
Have a quick question or need immediate guidance? Jump into our Microsoft Teams channel to chat with the team in real-time.

Report a bug on GitHub
Spotted an issue or a visual glitch? Open a GitHub ticket with a clear description and reproduction steps so we can squash it.