Animating accordions in JavaScript has been one of the most asked animations on websites. Fun fact: jQuery’s slideDown()
function was already available in the first version in 2006.
In this article, we will see how you can animate the native <details>
element using the Web Animations API.
HTML setup
First, let’s see how we are gonna structure the markup needed for this animation.
The <details>
element needs a <summary>
element. The summary is the content visible when the accordion is closed.
All the other elements within the <details>
are part of the inner content of the accordion. To make it easier for us to animate that content, we are wrapping it inside a <div>
.
<details>
<summary>Summary of the accordion</summary>
<div class="content">
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae!
At animi modi dignissimos corrupti placeat voluptatum!
</p>
</div>
</details>
Accordion class
To make our code more reusable, we should make an Accordion
class. By doing this we can call new Accordion()
on every <details>
element on the page.
class Accordion {
// The default constructor for each accordion
constructor() {}
// Function called when user clicks on the summary
onClick() {}
// Function called to close the content with an animation
shrink() {}
// Function called to open the element after click
open() {}
// Function called to expand the content with an animation
expand() {}
// Callback when the shrink or expand animations are done
onAnimationFinish() {}
}
Constructor()
The constructor is the place we save all the data needed per accordion.
constructor(el) {
// Store the <details> element
this.el = el;
// Store the <summary> element
this.summary = el.querySelector('summary');
// Store the <div class="content"> element
this.content = el.querySelector('.content');
// Store the animation object (so we can cancel it, if needed)
this.animation = null;
// Store if the element is closing
this.isClosing = false;
// Store if the element is expanding
this.isExpanding = false;
// Detect user clicks on the summary element
this.summary.addEventListener('click', (e) => this.onClick(e));
}
onClick()
In the onClick()
function, you’ll notice we are checking if the element is being animated (closing or expanding). We need to do that in case users click on the accordion while it’s being animated. In case of fast clicks, we don’t want the accordion to jump from being fully open to fully closed.
The <details>
element has an attribute, [open]
, applied to it by the browser when we open the element. We can get the value of that attribute by checking the open
property of our element using this.el.open
.
onClick(e) {
// Stop default behaviour from the browser
e.preventDefault();
// Add an overflow on the <details> to avoid content overflowing
this.el.style.overflow = 'hidden';
// Check if the element is being closed or is already closed
if (this.isClosing || !this.el.open) {
this.open();
// Check if the element is being openned or is already open
} else if (this.isExpanding || this.el.open) {
this.shrink();
}
}
shrink()
This shrink function is using the WAAPI .animate()
function. You can read more about it in the MDN docs. WAAPI is very similar to CSS @keyframes
. We need to define the start and end keyframes of the animation. In this case, we only need two keyframes, the first one being the current height the element, and the second one is the height of the <details>
element once it is closed. The current height is stored in the startHeight
variable. The closed height is stored in the endHeight
variable and is equal to the height of the <summary>
.
shrink() {
// Set the element as "being closed"
this.isClosing = true;
// Store the current height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the height of the summary
const endHeight = `${this.summary.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow or fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(false);
// If the animation is cancelled, isClosing variable is set to false
this.animation.oncancel = () => this.isClosing = false;
}
open()
The open
function is called when we want to expand the accordion. This function does not control the animation of the accordion yet. First, we calculate the height of the <details>
element and we apply this height with inline styles on it. Once it’s done, we can set the open attribute on it to make the content visible but hiding as we have an overflow: hidden
and a fixed height on the element. We then wait for the next frame to call the expand function and animate the element.
open() {
// Apply a fixed height on the element
this.el.style.height = `${this.el.offsetHeight}px`;
// Force the [open] attribute on the details element
this.el.open = true;
// Wait for the next frame to call the expand function
window.requestAnimationFrame(() => this.expand());
}
expand()
The expand function is similar to the shrink
function, but instead of animating from the current height to the close height, we animate from the element’s height to the end height. That end height is equal to the height of the summary plus the height of the inner content.
expand() {
// Set the element as "being expanding"
this.isExpanding = true;
// Get the current fixed height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the open height of the element (summary height + content height)
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow of fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(true);
// If the animation is cancelled, isExpanding variable is set to false
this.animation.oncancel = () => this.isExpanding = false;
}
onAnimationFinish()
This function is called at the end of both the shrinking or expanding animation. As you can see, there is a parameter, [open]
, that is set to true when the accordion is open, allowing us to set the [open]
HTML attribute on the element, as it is no longer handled by the browser.
onAnimationFinish(open) {
// Set the open attribute based on the parameter
this.el.open = open;
// Clear the stored animation
this.animation = null;
// Reset isClosing & isExpanding
this.isClosing = false;
this.isExpanding = false;
// Remove the overflow hidden and the fixed height
this.el.style.height = this.el.style.overflow = '';
}
Setup the accordions
Phew, we are done with the biggest part of the code!
All that’s left is to use our Accordion
class for every <details>
element in the HTML. To do so, we are using a querySelectorAll
on the <details>
tag, and we create a new Accordion
instance for each one.
document.querySelectorAll('details').forEach((el) => {
new Accordion(el);
});
Notes
To make the calculations of the closed height and open height, we need to make sure that the <summary>
and the content always have the same height.
For example, do not try to add a padding on the summary when it’s open because it could lead to jumps during the animation. Same goes for the inner content — it should have a fixed height and we should avoid having content that could change height during the opening animation.
Also, do not add a margin between the summary and the content as it will not be calculated for the heights keyframes. Instead, use a padding directly on the content to add some spacing.
The end
And voilà, we have a nice animated accordion in JavaScript without any library! 🌈
Very nice but that feels like a LOT of work for essentially a simple accordion.
I’m using two CSS Custom Props to hold the “collapsed” and “expanded” heights, and then a small JavaScript to calculate these. There’s a ResizeObserver to recalculate these when widths change.
The details-element:
height: var(–collapsed);
And on [open]:
height: var(–expanded);
Made a Codepen-demo here:
Oh yeah pre-calculating the open & close values is clever!
I’ve never used ResizeObserver before but it’s super useful (and browser support is great ❤)
Thanks for sharing your demo
I should also mention that you can animate the details-tag without JavaScript, if it’s not height, ie. something like a mega-menu or toggle-tips, that don’t push content down. However, you can’t use transitions on the [open]-state, but animations work fine. I’ve done a demo here for toggle-tips: https://codepen.io/stoumann/pen/abZXxPx
And … In my first comment I forgot to say thank you for a great article!
The problem with this script it that it closes the details open.
Your width re-measuring in fact does not work. The width never changes, therefore the resize observer never fires. If you try this on an element that actually will resize, you’ll find that it measures both open and closed heights at the closed height.
Do you actually know how to animate Dom properties like scrollTop using waapi. To me it seems that I can only animate css but being able to animate anything was one crucial use case to add a ja based animation api
No sadly WAAPI cannot animate any JavaScript variable or else. It’s really about animating Dom elements the same way CSS animations would.
To animate scrollTop you could create a variable with the scroll position an animate it with requestAnimationFrame. On each frame you would apply the new scroll position to the window. Or use a library like GreenSock to do all that for you :)
So you want to animate from a numeric value to another numeric value, maybe to an element-position, captured with
getBoundingClientRect()
?Below I’ve made a function you can call with a to and from numeric value.
The element is the element to scroll within (defaults to body-tag), dir is the scroll direction (defaults to
0
, which is vertical, and1
will be horizontal),duration
is in milliseconds, andeasing
is the type of easing as a function. I’ve included a few easing-functions, get more here: https://easings.net/Example: scroll to scroll-position
1200
, usescrollFromTo(0, 1200)
.Alexander, here’s a demo for the animation-code in my previous comment: https://codepen.io/stoumann/full/QWEoWPN
Very nice. Great stuff!
My only suggestion – and I haven’t yet tried to sort it out myself – would be to make the open/close duration a function of the height. As it is, the larger the height, the faster the speed. That is, the open or close has to cover more distance in the same fixed amount of time.
This is not a complaint. I am using this already :) Thanks! It’s simply something I noticed that would be nice to consider.
Thanks for the article. Because I like to use web components, I modified this demo using LitElement, and added some custom properties for styling: https://codepen.io/johnthad/pen/wvzgzYx
This is great — agreed, a lot of runaround for just making the details element animate, but I’d much rather use the details element for accordions, and most of the time the client wants that animated, and damn it feels good.
One bonus would be to add a check for
prefers-reduced-motion
to skip the animation all together if a user has it enabled on their os. You could do that logic when calling your accordion class on an element, but I’ve added some classes to the various components of the accordion in there, so it made more sense for me to do that logic in the Accordion class. You could add a check to the constructor:And then in the
shrink
andexpand
methods just wrap all of the WAAPI stuff in:And then probably do an
else
to call theonAnimationFinish
method.So all together my
shrink
method for example, looks like this:I made a version using pure CSS only, no JavaScript:
I also wrote a post explaining how to do it:
https://dev.to/jgustavoas/how-to-fully-animate-the-details-html-element-with-only-css-no-javascript-2n88
That is very interesting! I don’t really understand why the transition works on the closing with the input trick instead of using the
detail[open]
selector…There are two issues still with this solution. The first being that it sadly is doesn’t work on Safari. The second is if you have content higher than then set max-height.
Also the transition duration is biased if you have a 100px height content next to a 800px height, as the first transition would happen faster but it’s not so dramatic :)
Thank you for your feedback, Louis!
It seems to be because of the poor support of Safari for the
<summary>
element and the::marker
pseudo-element.It is also odd to note that in Firefox on MacOS this solution doesn’t work at all, unlike other OS, where only the approach with the
:has()
pseudo-class doesn’t work in Firefox by default.