Accordions are one of the most common UI patterns on the web — FAQ sections, product specification lists, and sidebar navigation menus all frequently use a show/hide toggle pattern. jQuery UI and third-party plugins offer ready-made accordions, but they add unnecessary weight to a WordPress theme when the interaction logic can be written in fewer than thirty lines of vanilla JavaScript. A proper accordion has three requirements beyond the basic click toggle: it must be keyboard-navigable (Enter and Space open/close a panel, arrow keys move between headers), it must communicate state to assistive technologies via aria-expanded on the trigger and aria-hidden on the panel, and the open/close animation should use CSS transitions rather than JavaScript-driven height changes for smooth GPU-accelerated motion. The animation challenge with accordions is that height: auto cannot be transitioned directly — the standard solution is to use max-height with a sufficiently large maximum value, or to measure the scrollHeight of the panel in JavaScript and set it explicitly before animating to zero. The scrollHeight approach is more precise and avoids the “jump-then-animate” artefact that can occur with large max-height values. This component uses event delegation on a parent container so it works with accordions injected into the DOM after page load — a common scenario in WordPress when content is loaded via AJAX or rendered by a page builder. The component is self-contained and can be initialised on any element with a data-accordion attribute. Combine this with the event delegation guide and the Intersection Observer guide for a complete interactive-component toolkit.
Problem: You need an accessible, animated FAQ or content accordion in a WordPress theme without importing jQuery UI or a third-party library.
Solution: Write a self-contained vanilla JS accordion that manages aria-expanded, aria-hidden, and scrollHeight-based animation:
/**
* Accordion — initialise on any element with [data-accordion].
* Expected markup:
* <div data-accordion>
* <div class="ac-item">
* <button class="ac-trigger" aria-expanded="false">Title</button>
* <div class="ac-panel" aria-hidden="true">Content</div>
* </div>
* </div>
*/
function initAccordion( root ) {
root.addEventListener( 'click', function ( e ) {
const trigger = e.target.closest( '.ac-trigger' );
if ( ! trigger ) return;
const item = trigger.closest( '.ac-item' );
const panel = item.querySelector( '.ac-panel' );
const isOpen = trigger.getAttribute( 'aria-expanded' ) === 'true';
// Close all other items (single-open mode)
root.querySelectorAll( '.ac-item' ).forEach( function ( other ) {
if ( other === item ) return;
const otherTrigger = other.querySelector( '.ac-trigger' );
const otherPanel = other.querySelector( '.ac-panel' );
otherTrigger.setAttribute( 'aria-expanded', 'false' );
otherPanel.setAttribute( 'aria-hidden', 'true' );
otherPanel.style.height = '0';
} );
// Toggle current item
if ( isOpen ) {
trigger.setAttribute( 'aria-expanded', 'false' );
panel.setAttribute( 'aria-hidden', 'true' );
panel.style.height = '0';
} else {
trigger.setAttribute( 'aria-expanded', 'true' );
panel.setAttribute( 'aria-hidden', 'false' );
panel.style.height = panel.scrollHeight + 'px';
}
} );
// Keyboard: arrow keys move between triggers
root.addEventListener( 'keydown', function ( e ) {
const trigger = e.target.closest( '.ac-trigger' );
if ( ! trigger ) return;
const triggers = Array.from( root.querySelectorAll( '.ac-trigger' ) );
const idx = triggers.indexOf( trigger );
if ( e.key === 'ArrowDown' ) triggers[ Math.min( idx + 1, triggers.length - 1 ) ].focus();
if ( e.key === 'ArrowUp' ) triggers[ Math.max( idx - 1, 0 ) ].focus();
} );
}
// Initialise all accordions on the page
document.querySelectorAll( '[data-accordion]' ).forEach( initAccordion );
.ac-panel {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}
.ac-trigger {
width: 100%;
text-align: left;
background: none;
border: none;
cursor: pointer;
padding: 1rem;
font-size: 1rem;
}
.ac-trigger[aria-expanded="true"] .ac-icon {
transform: rotate(180deg);
}
NOTE: The scrollHeight-based approach requires that the panel has no top or bottom padding when closed — move inner padding to a child element inside .ac-panel instead. If you are using the accordion in a WordPress block pattern or template, enqueue the JavaScript via wp_enqueue_script() with array() as the dependencies (no jQuery needed) and true as the last argument to load it in the footer. To support multiple-open mode, remove the “close all other items” loop and just toggle the current item.