Building an animated <details> element

Date: , Updated: — Topic: — Langs: , , — by Slatian

Accordion elements on the web seem to be everywhere and almost everyone seems to implement them in a way that only works for the well sighted user with a modern browser with JavaScript enabled. I want to show that these can be built in a better way.

So why are accordions used at all?

While using accordions or expanders in some situations is a bad idea, sometimes they help with uncluttering, hiding information that isn't needed by most people but available from the already loaded document on request when needed.

An example would be to provide contact details on a page, usually visitors don't care, but when they do, that information is easy to find.

Another example would be an event log with the summaries always being visible and the details being available inline on request. sourcehut and YunoHost use this pattern.

Note: Accordions usually are multiple collapsible elements grouped together and only allow one open at a time. I'm not going into that functionality here.

The HTML way of building an accordion …

This pattern is so useful that HTML has elements for exactly that usecase, the <details> and <summary> elements, one simply puts a summary inside a details tag along with some content and it works, in all browsers, without JavaScript, with any input method the browser supports.

Note: Apparently the details element and accessibility is it's own can of worms, too. As the article mentions: You decide.

Example of such a details element
<details>
	<summary>Your DNS records for example.org are not configured</summary>
	<p>Add an <code>A</code> record pointing to <code>93.184.216.34</code>.</p>
	<p>Add an <code>AAAA</code> record pointin to <code>2606:2800:220:1:248:1893:25c8:1946</code></p>
	<p>It is important to set both, otherwise peopley may be unable to access your server.</p>
</details>

It then would be displayed similar to this:

Your DNS records for example.org are not configured

Add an A record pointing to 93.184.216.34.

Add an AAAA record pointing to 2606:2800:220:1:248:1893:25c8:1946

It is important to set both, otherwise people may be unable to access your server.

What people are doing instead …

You may have been surprised by the fact that there is an HTML element for exactly this purpose because you have never seen one in the wild.

There are probably three reasons people don't use it:

The result being that they give up and write their own accordion from scratch, button onclick handler, element with changing styles, done. Except that the result is usually an accessibility nightmare, and completely falls apart when for some reason the JavaScript needed to operate it isn't available.

Why is the details element seemingly hard to animate?

Animating a details tag is simple, one can use the [open=""] CSS selector to find out whether the details element is currently open, put some keyframes and an animation (more details later) on it and it opens with a smooth animation and closing is … not animated at all.

No your CSS isn't at fault here.

How the details element works.

The details element is a bit of a unicorn in that it manipulates the page tree. When the content gets shown elements are added and when it closes the elements get removed from the page instantly, and while that is great as it saves resources PR is not happy because they want an animation.

My personal guess is that this is where most developers who know about the details element give up.

Animating it Anyway

So we want an animation for sighted people with modern browsers and a good experience for everyone else, so lets try to achieve that by using the web as it was intended, HTML for content, CSS for eye candy and JavaScript for providing some optional functionality.

Note: Animating slightly degrades accessibility (which is probably still better than with your completely custom accordion) of the details element.

You can find the final result over on Codeberg.

Let's start with the content, I'll assume you have a plain details and summary tag filled with your content and we want to leave it as it is.

<details>
	<summary>Your DNS records for example.org are not configured</summary>
	<p>Add an <code>A</code> record pointing to <code>93.184.216.34</code>.</p>
	<p>Add an <code>AAAA</code> record pointin to <code>2606:2800:220:1:248:1893:25c8:1946</code></p>
	<p>It is important to set both, otherwise peopley may be unable to access your server.</p>
</details>

In case you want to follow along but only have a desktop browser: open a new tab, navigate to about:blank and open up the developer tools. Depending on the browser you now have a pretty good IDE with live preview.

The Grand Opening

Before we worry about closing the element, lets worry about it opening.

The simplest way here is to simply animate the max-height property from the current height of the element (--details-current-height) to one screen height (100vh), filling only back in time in case the details element is taller than one screen height (i.e. on a smaller device), in that case it will simply snap to full height off screen and is fully viewable.

Because we currently have no idea what the current height of the details element is, falling back to an assumed 1em as the height of our summary element works pretty well. (You may have to adjust this based on extra padding and margin you are applying)

@keyframes details-appear {
	from {
		max-height: var(--details-current-height, 1em);
	}
	to {
		max-height: 100vh;
	}
}

details[open=""] {
	animation: 1s linear details-appear;
	animation-fill-mode: backwards;
	/*needed for the max-height animation to work*/
	overflow: hidden;
}

With that the opening part is taken care of.

Fading out of Existence

Because the details element immediately closes and there is no way to animate that we have to put a delay between the actual interaction with the element and the open attribute being removed, so let's add some JavaScript.

Because we want to hook on the interaction of a sighted user with the summary element, we use an onclick handler here and assume a details_click_handler() function.

The following function simply adds the onclick handler to every summary element on the page (if you only want to target specific elements … I'll leave that as an exercise).

function initalize_animated_details() {
	var details_elements = document.getElementsByTagName("summary");
	for ( let i = 0; i < details_elements.length; i++) {
		details_elements[i].onclick = details_click_handler;
	}
}

To run this initalizer you can use any method you like, but since in my experience document.onload is a bit wonky when testing inline code on small documents here is a way to defer it until the first interaction.

document.onclick = (event) => {
	/* we only need it once */
	document.onclick = undefined;
	initalize_animated_details();
	/* if the event was for a summary tag run the handler */
	if (event.target.tagName === "SUMMARY") {
		return details_click_handler(event);
	}
	/* Otherwise let the browser handle the click */
	return true;
}

With that set up we can now focus on the click handler, which has to do two things:

A bare minimum implementation of the details_click_handler
function details_click_handler(event) {
	let details = event.target.parentElement;
	/* When opening, let the browser do its job */
	if (!details.open) {
		return true;
	}
	details.classList.add("closing");
	setTimeout(function() {
		details.open = false;
		details.classList.remove("closing");
	}, 500);
	return false;
}
Fun fact: if you try that one out right now you'll get the experience of someone who is unable to see the closing animation.

With the bare minimum of JavaScript added let's add some CSS to get a proof of concept animated details element. This is basically the reversed opening from the current height, defaulting to 100vh to the height of the summary element defaulting to 1em again.

Again we'll worry about actually setting those variables to something useful later.

@keyframes details-disappear {
	from {
		max-height: var(--details-current-height, 100vh);
	}
	to {
		max-height: var(--details-summary-height, 1em);
	}
}

details.closing {
	animation: var(--details-close-animation-length, .5s) linear details-disappear;
	overflow: hidden;
	max-height: var(--details-summary-height, 1em);
}

Now you should have a not pretty but animated closing details element.

Improving the Animation

Now that we have a proof of concept, lets make it usable.

The issues we currently have are:

Handling an interaction while the animation is playing

Currently when one interacts while the animation is playing the behaviours is that the element keeps closing, which is not great.

To fix it we have to add some additional checks to our handler function to test if the closing class is present on an open element. If that's the case we just remove the class gain instead of launching a new timeout and on the timeout part we just discard the timeout if the closing class isn't present.

Lowering the impact on accessibility

There are two problems I'm trying to solve with this one:

Each of the alone would be enough of a reason (at least for me) to not animate the closing of the element on keyboard interaction. (Fixing this is probably possible but at that point we are writing a full blown expander in JavaScript.)

Based on this we make a simple assumption: If you are not using a pointing device, you are probably not in the group that greatly benefits from an animation.

Note: Of course the "uses a pointer" is a heuristic that will be sometimes be wrong.

To achieve this one can test for the offsetX and offsetY values of the event to find out weather the event came from a pointer or not, both are 0 they probably didn't come from a pointer and we tell the browser to do what it would do without us interfering.

To stop the opening animation from playing when we won't deliver a corresponding closing animation we add an animated class as an additional requirement in our two animation CSS rules.

The offset check to only play animations for events from pointer devices
function details_click_handler(event) {
	let details = event.target.parentElement;
	if (event.offsetX == 0 && event.offsetY == 0) {
		details.classList.remove("animated");
		return true;
	}
	details.classList.add("animated");
	// … Rest of the function goes here …
}

This way we can make PR and our bosses happy while mostly staying out of the way of people who appreciate clean websites more than fancy animations.

Note that after this the animation won't play if the JavaScript isn't running (The element will just snap open and closed like at the start).

Deglitching the animation

Now that we have taken care of accessibility issues, we can put the CSS variables introduced earlier to use to make the animation responsive to the current state to avoid some glitches.

This means populating the --details-summary-height and --details-current-height properties directly on the details elements.

This code is best placed after the offset check so that the properties are already set when opening and closing
let summary_height = event.target.getBoundingClientRect().height;
let current_height = details.getBoundingClientRect().height;
details.style.setProperty('--details-summary-height', summary_height+"px");
details.style.setProperty('--details-current-height', current_height+"px");

Getting the timings to match

After that is fixed the last open issue is, that the speed of the animation opening and the speed of the animation closing don't match.

With the opening speed currently being one screen height minus summary per second it is pretty easy to get the closing animation to match that as when the accordion is open we can just ask the browser how tall the details and summary elements are, how tall the screen is and calculate the animation length from that. Since we already know the heights of both relevant elements we'll use the variables from earlier.

This code calculating and setting the animation length should be put right before we add the closing CSS class
let close_animation_duration = (current_height-summary_height)/window.innerHeight;
details.style.setProperty('--details-close-animation-length', close_animation_duration+"s");

To make the actual state match the timeout previously set to 500 should now be close_animation_duration*1000+10 the extra 10ms is to allow the browser a bit of breathing room so that we don't cut the animation off before it has finished.

The final result

The resulting details_click_handler function now should look like the following.

function details_click_handler(event) {
	let details = event.target.parentElement;
	// if called for a non-pointer event don't play animations
	if (event.offsetX == 0 && event.offsetY == 0) {
		details.classList.remove("animated");
		return true;
	}
	details.classList.add("animated");
	// tell the animations where to start and where to end
	let summary_height = event.target.getBoundingClientRect().height;
	let current_height = details.getBoundingClientRect().height;
	details.style.setProperty('--details-summary-height', summary_height+"px");
	details.style.setProperty('--details-current-height', current_height+"px");
	// log the event for debugging
	// console.log("click", event);
	// let the browser handle the opening
	if (!details.open) {
		return true;
	}
	if (details.classList.contains("closing")) {
		// abort a closing animatiton when interrupted
		details.classList.remove("closing");
	} else {
		// calculate close animation length
		let close_animation_duration = (current_height-summary_height)/window.innerHeight;
		details.style.setProperty('--details-close-animation-length', close_animation_duration+"s");
		// trigger closing animation and correponding timeout
		details.classList.add("closing");
		setTimeout(function() {
			// discard timeout if someone interrupted the animation
			if ( details.classList.contains("closing")) {
				// properly close the element and clean up the styleclass
				details.open = false;
				details.classList.remove("closing");
			}
		}, close_animation_duration*1000+10);
	}
	return false;
}

And our final CSS:

@keyframes details-appear {
	from {
		max-height: var(--details-current-height, 1em);
	}
	to {
		max-height: 100vh;
	}
}

@keyframes details-disappear {
	from {
		max-height: var(--details-current-height, 100vh);
	}
	to {
		max-height: var(--details-summary-height, 1em);
	}
}

details.animated[open=""] {
	animation: 1s linear details-appear;
	animation-fill-mode: backwards;
	overflow: hidden;
}

details.animated.closing {
	animation: var(--details-close-animation-length, .5s) linear details-disappear;
	overflow: hidden;
	max-height: var(--details-summary-height, 1em);
}

Updates

2023-03-25