Accordion UI components are one of the most common patterns in FAQ sections, product feature lists, and support documentation pages — and they require no JavaScript library when built with vanilla ES6 and a handful of CSS transitions. The <details> and <summary> HTML elements provide native accordion behaviour without any JavaScript, but they lack smooth animation support across all browsers without extra scripting. A JavaScript-driven accordion gives full control over animation, keyboard navigation, and ARIA attributes, making it more accessible than a naive implementation. Setting aria-expanded on the toggle button and aria-hidden on the panel communicates the open or closed state to screen readers without additional markup. Using max-height CSS transitions instead of display: none allows the browser to animate the panel open and closed smoothly. Reading the scrollHeight of each panel on toggle and assigning it as the max-height value produces a reliable animation regardless of dynamic content. An allow-multiple data attribute on the accordion container makes the open-one-at-a-time versus open-many behaviour configurable per instance without code changes. Keyboard support following the WAI-ARIA Accordion pattern requires handling Arrow Down, Arrow Up, Home, and End keys in the toggle button’s keydown listener. Delegating click and key events to the container element rather than individual buttons supports dynamically inserted accordion items without rebinding listeners. The localStorage guide explains how to persist the open-panel state across page loads — useful for long FAQ pages where users frequently leave and return. The WooCommerce quantity button post follows a similar event delegation pattern for DOM-heavy commerce pages. Adding a prefers-reduced-motion media query override that disables the max-height transition respects users who have configured their OS to minimise motion for accessibility reasons. Enqueue the script with type="module" or wrap it in a self-invoking function to avoid polluting the global scope in a WordPress theme that may load other scripts.
Problem: FAQ sections on WordPress pages typically rely on jQuery plugins or page-builder widgets for accordion behaviour, adding unnecessary dependencies for a UI pattern that needs only a small amount of vanilla JavaScript.
Solution: Build a self-contained vanilla ES6 accordion class that handles click and keyboard events via delegation, animates panels with max-height transitions, and sets correct ARIA attributes on each toggle.
class Accordion {
constructor(el) {
this.el = el;
this.allowMulti = el.hasAttribute('data-allow-multiple');
this.items = Array.from(el.querySelectorAll('.accordion-item'));
el.addEventListener('click', e => this.onToggle(e));
el.addEventListener('keydown', e => this.onKey(e));
this.items.forEach(item => this.setAttributes(item, false));
}
getToggle(item) { return item.querySelector('.accordion-toggle'); }
getPanel(item) { return item.querySelector('.accordion-panel'); }
setAttributes(item, open) {
const toggle = this.getToggle(item);
const panel = this.getPanel(item);
toggle.setAttribute('aria-expanded', String(open));
panel.setAttribute('aria-hidden', String(!open));
panel.style.maxHeight = open ? panel.scrollHeight + 'px' : '0';
}
open(item) { this.setAttributes(item, true); }
close(item) { this.setAttributes(item, false); }
onToggle(e) {
const toggle = e.target.closest('.accordion-toggle');
if (!toggle) return;
const item = toggle.closest('.accordion-item');
const isOpen = toggle.getAttribute('aria-expanded') === 'true';
if (!this.allowMulti) this.items.forEach(i => i !== item && this.close(i));
isOpen ? this.close(item) : this.open(item);
}
onKey(e) {
const toggle = e.target.closest('.accordion-toggle');
if (!toggle) return;
const toggles = this.items.map(i => this.getToggle(i));
const idx = toggles.indexOf(toggle);
const map = { ArrowDown: idx + 1, ArrowUp: idx - 1, Home: 0, End: toggles.length - 1 };
if (e.key in map) {
e.preventDefault();
const next = toggles[map[e.key]];
if (next) next.focus();
}
}
}
document.querySelectorAll('.accordion').forEach(el => new Accordion(el));
NOTE: The CSS counterpart requires .accordion-panel { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } — without the overflow: hidden declaration the panel content is visible even when max-height is zero.