How to Make an Accessible Accordion Component

Without a Framework or Library

Recently, I came across an example of an accordion that was stripped right out of the Flowbite documentation. This example uses an insane amount of HTML markup, almost zero semantic value, and is not accessible whatsoever to people using assistive technology.

I found myself wondering why on earth someone would make it so abstract and difficult to make an accordion when the native tools we’re using offer us an almost perfect option right out of the box. This can be perfected with just a little bit of code.

Let’s get to it.

The Markup

HTML5 natively offers us an accordion in the form of the <details> element. I’ve recreated the example from Flowbite with the proper semantic tags in the following Codepen.

https://codepen.io/wesleysmits/pen/yLpGVEG?editors=1000

Obviously, it could use some styling, and an animation on the opening/closing would be nice. But it’s a great starting point that is accessible to all potential users and clearly communicates its semantic value to any device.

The Styling

For styling, I will be recreating the example from Flowbite as well, but without the 100 utility classes to show how easy it is to style a simple accordion such as this without loading in an entire library of CSS utilities bloating up the HTML.

Starting with the basic page styling, a simple background on the body and some margin on all paragraph tags will be enough to set the mood.

body {
background: #001122;
}
p {
margin: 1rem 0;
}
p:first-child {
margin-top: 0;
}

For the actual accordion, the <details> tag will receive a max-width, a border, and color. The <summary> tag will get relative positioning for the arrow indicator we want to add, a display: block; that will hide the native open indicator on modern browsers, some padding, and a background-color.

Furthermore, we’re hiding the WebKit details marker and adding an open indicator in the form of an arrow to the summary:after pseudo-selector. The arrow should rotate on the open state and there should be some padding on the accordion content.

details {
–curtain-border-color: #334455;
–curtain-background-color: #223344;
–curtain-color: #ffffff;
max-width: 30rem;
border: 1px solid var(–curtain-border-color);
color: var(–curtain-color);
overflow: hidden;
}
summary {
position: relative;
display: block;
padding: 1rem 3.5rem 1rem 1rem;
background: var(–curtain-background-color);
}
summary::-webkit-details-marker {
display: none;
}
summary:focus {
outline: none;
box-shadow: inset 0 0 0.25em 0.125em #08f;
}
summary:after {
content: "";
position: absolute;
top: 1.375rem;
right: 1rem;
display: inline-block;
vertical-align: top;
width: 0;
height: 0;
border: 0.5rem solid transparent;
border-top-color: #ffffff;
border-bottom-width: 0;
transition: transform 0.5s;
}
details[open] summary:after {
transform: rotateX(180deg);
}
.curtain__content {
padding: 1rem;
}

You can see the styled version of the accordion on the following codepen.

The Logic

For the purposes of this article, I want to extend the functionality of the <details> element through a Web Component. I want to use the “Customised Built-in” version that is not available for Safari without a polyfill. It does however keep our semantics 100% intact.

The polyfill is available with the following script:

<script src="//unpkg.com/@ungap/custom-elements/es.js"></script>

With that out of the way let’s think about the requirements of this custom component.

  1. When the user clicks on the <summary>, the click event should be captured. Natively this happens without any animation, so in this case, event.preventDefault() should be used to remove the native functionality and implement a custom open/close method.
  2. The element should be animated to the correct height. It’s fairly tricky to animate the height through CSS. Since it is such a lightweight animation and the component would work fully without the JS I think it’s perfectly acceptable to add a JS animation in this case.

This boils down to the following component, which includes a single utility function that I didn’t feel belonged in this class:

import { getAbsoluteHeight } from './utils.js';
class Curtain extends HTMLDetailsElement {
#summary = this.querySelector('summary');
#content = this.querySelector('.curtain__content');
#animation = null;
connectedCallback() {
this.#summary.addEventListener('click', this.#handleClick);
}
disconnectedCallback() {
this.#summary.removeEventListener('click', this.#handleClick);
}
#handleClick = (event) => {
event.preventDefault();
if (this.open === false) {
this.#open();
return;
}
this.close();
};
toggle() {
if (this.open === true) {
this.close();
return;
}
this.#open();
}
#open() {
this.style.height = `${this.offsetHeight}px`;
if (!this.open) {
this.open = true;
}
window.requestAnimationFrame(() => {
const startHeight = `${this.offsetHeight}px`;
const endHeight = `${this.#summary.offsetHeight + getAbsoluteHeight(this.#content)}px`;
if (this.#animation) {
this.#animation.cancel();
}
this.classList.add('nh-curtain-new–open');
this.#animation = this.animate(
{
height: [startHeight, endHeight]
},
{
duration: 400,
easing: 'ease-out'
}
);
this.#animation.onfinish = () => this.#onAnimationFinish(true);
});
}
close() {
const startHeight = `${this.offsetHeight}px`;
const endHeight = `${this.#summary.offsetHeight}px`;
if (this.#animation) {
this.#animation.cancel();
}
this.classList.remove('nh-curtain-new–open');
this.#animation = this.animate(
{
height: [startHeight, endHeight]
},
{
duration: 400,
easing: 'ease-out'
}
);
this.#animation.onfinish = () => this.#onAnimationFinish(false);
}
#onAnimationFinish(open) {
this.open = open;
this.#animation = null;
this.style.height = '';
}
}
customElements.define('curtain-component', Curtain, { extends: 'details' });
export function getAbsoluteHeight(el) {
const styles = window.getComputedStyle(el);
const margin = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
return Math.ceil(el.offsetHeight + margin);
}

Bonus Feature — Accordion With Only One Active Tab

There are numerous use cases where a set of accordions is linked to each other and you want to only have one of them active at a given time. For this, you can simply introduce a wrapper component that handles the active state of all child elements.

I will simply call the component <custom-accordion>.

class Accordion extends HTMLElement {
#curtains = [];
connectedCallback() {
this.#curtains = Array.from(this.querySelectorAll('.nh-curtain-new'));
this.#curtains.forEach((curtain) => {
curtain.addEventListener('toggle', this.#handleToggle.bind(this, curtain));
});
}
disconnectedCallback() {
this.#curtains.forEach((curtain) => {
curtain.removeEventListener('toggle', this.#handleToggle.bind(this, curtain));
});
}
#handleToggle(curtain) {
if (!curtain.open) {
return;
}
this.#closeOtherCurtains(curtain);
}
#closeOtherCurtains(curtain) {
const otherCurtains = this.#curtains.filter((c) => curtain !== c);
otherCurtains.forEach((otherCurtain) => {
const animations = otherCurtain.getAnimations();
if (animations.length === 0) {
otherCurtain.close();
return;
}
Promise.all(animations.map((animation) => { animation.finished })).then(() => {
otherCurtain.close();
});
otherCurtain.close();
});
}
}

Conclusion

Here I have created a simple accessible and semantic accordion that is easily understandable by developers and machines. We are giving users with assistive technology access to the content and we aren’t loading any unnecessary libraries to do basic stuff for us.

PS: We did have to load in one polyfill for the built-in custom elements in Safari. It’s fine to leave this out and give Safari users an experience without the animation. It’s also fine to slightly refactor this code to pass in an array of detail elements into the class and enhance them without a web component.

Using a web component seems to be the future-proof way of building components and browser support is great these days.

You can find the full code on my GitHub repo or a working example of this accordion on this Netlify link. I hope you learned something from this simple tutorial. If you have any thoughts, questions, feedback, or improvements, please let me know in the comments.

You can follow me on Twitter or buy me a Ko-fi or subscribe to Medium through my link (affiliate) if you’d like to support me.

Happy Coding!

LEAVE A REPLY

Your email address will not be published. Required fields are marked *