Accordions turn up regularly in agency website designs. They can be useful for delivery information, frequently asked questions and supporting details on a product page. They also make it tempting to hide a lot of content without asking whether people need to compare or read it all.
When an accordion is appropriate, I want a small, understandable component. In my WordPress and Magento 1 work, it needs to cope with the content a client adds and the ways a customer might use the page. A click animation is only one part of that.
What we are building
This example is a group of independent show-and-hide sections. More than one answer can stay open, which helps someone compare the information. Each control is a native button, with its expanded state exposed to assistive technology. It does not need a JavaScript library.
Before adding it to a theme, I check whether the content should be collapsed at all. A short answer may be better left visible. Essential purchase information should not become difficult to discover simply because an accordion makes the design look tidier.
1. Start with readable content
The HTML contains ordinary headings and answers. None of the answers is hidden in the initial markup. If the script is unavailable, the result is still a readable list of questions and answers, with working links.
<div class="faq">
<h3 data-panel="delivery-answer">When will my order arrive?</h3>
<div id="delivery-answer">
<p>Delivery times depend on the service selected at checkout.</p>
<p><a href="/delivery/">View our delivery information</a></p>
</div>
<h3 data-panel="returns-answer">Can I return an item?</h3>
<div id="returns-answer">
<p>Read the returns policy before arranging a return.</p>
<p><a href="/returns/">View our returns policy</a></p>
</div>
</div>
The data-panel value points to the answer’s unique ID. In a WordPress template or a Magento block, I would generate unique IDs for each instance so two accordions cannot accidentally control the same answer. The text and links here are placeholders for the shop’s actual policies.
2. Add buttons and keep the state together
Place this script after the accordion markup. It turns each heading into a button and only then collapses its answer. The heading remains in place around the control, so the questions still form part of the page structure.
(function () {
var headings = document.querySelectorAll('.faq h3[data-panel]');
function enhance(heading) {
var panel = document.getElementById(heading.getAttribute('data-panel'));
if (!panel) {
return;
}
var button = document.createElement('button');
button.type = 'button';
button.textContent = heading.textContent;
button.setAttribute('aria-controls', panel.id);
function setExpanded(expanded) {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
panel.hidden = !expanded;
}
button.addEventListener('click', function () {
setExpanded(button.getAttribute('aria-expanded') !== 'true');
});
heading.textContent = '';
heading.appendChild(button);
setExpanded(false);
}
for (var i = 0; i < headings.length; i += 1) {
enhance(headings[i]);
}
}());
The setExpanded function updates both the button’s aria-expanded attribute and the answer’s visibility. Keeping those changes together avoids a visible answer being described as closed, or a hidden answer being described as open. The aria-controls attribute identifies the answer the button operates.
I use the button’s normal click behaviour rather than adding a separate keyboard handler. Native buttons already respond to Enter and Space. Giving a heading a click handler on its own would leave us responsible for recreating keyboard and focus behaviour.
3. Use CSS to make the control clear
I give the button a generous area to press, a visible focus outline and a clear indication of whether its answer is open. The styles below are intentionally plain so they can be adapted to the website’s design.
.faq h3 {
margin: 0;
}
.faq button {
display: block;
width: 100%;
padding: 1em;
border: 0;
border-bottom: 1px solid #777;
background: #fff;
color: #222;
font: inherit;
text-align: left;
cursor: pointer;
}
.faq button:hover,
.faq button[aria-expanded="true"] {
background: #eee;
}
.faq button:focus {
position: relative;
outline: 3px solid #222;
outline-offset: 2px;
}
.faq [hidden] {
display: none !important;
}
.faq button[aria-expanded="true"]::after {
content: ' (hide)';
}
.faq button[aria-expanded="false"]::after {
content: ' (show)';
}
The hidden rule ensures a theme’s display styles do not accidentally reveal a collapsed panel. Hidden answers also keep their links out of the normal keyboard sequence. I would check that behaviour alongside the visual styles when moving this into a larger theme.
There is no slide animation here. Animating to an arbitrary maximum height can cut off a longer answer or produce strange timing. I would first get the content and interaction right, then consider whether movement adds anything useful.
Checking the whole interaction
I test opening and closing with a pointer, then use Tab, Enter and Space without the mouse. The control should keep focus when it is used. Links should be reachable when their answer is open and skipped when it is closed.
I also check the unenhanced page, a long answer, repeated instances and the browser requirements for the project. A screen-reader check helps establish whether the question and its changing state are understandable. A visual test alone cannot establish that.
In the CMS, this component needs to remain ordinary content the client can maintain. I keep the questions and answers separate from the interaction code, rather than asking an editor to manage scripts in the content field. On a shop, I also check that nearby product forms and options still behave as expected.
What I like about this approach is how little it needs to do. The content is there first, the script adds a useful interaction, and the styling can follow the design. That makes it easier to understand, reuse and adjust when a client changes the page.