Automatically detecting when transitions end with vanilla JavaScript
Yesterday, we looked at two techniques for adjusting the timing of transitions for our vanilla JS show()
and hide()
methods. Both of approaches involved hard-coding timing values into our script.
Imagine if you had multiple transition types: some with a fade-in, some without; some with an animation, some without; some fast, some slow, others somewhere in-between. Controlling all of that logic with manual JS arguments or if...else
logic would quickly become unmanageable.
Reader Diego Versiani sent me an email with a really cool technique for automatically detecting when transitions end. His approach makes scaling larger projects much more maintainable.
Side Note: I love getting emails like this. If you have a tip or technique you want to share with the rest of my readers, please reach out and let me know about it!
A Quick Recap
Just to quickly recap, here’s our current working script.
// Get the transition timing
var getTiming = function (elem) {
var timing = 350;
if (elem.classList.contains('show-fast')) {
timing = 100;
}
if (elem.classList.contains('show-slow')) {
timing = 2000;
}
return timing;
};
// Show an element
var show = function (elem) {
// Get the transition timing
var timing = getTiming(elem);
// Get the natural height of the element
var getHeight = function () {
elem.style.display = 'block'; // Make it visible
var height = elem.scrollHeight + 'px'; // Get it's height
elem.style.display = ''; // Hide it again
return height;
};
var height = getHeight(); // Get the natural height
elem.classList.add('is-visible'); // Make the element visible
elem.style.height = height; // Update the height
// Once the transition is complete, remove the inline height so the content can scale responsively
window.setTimeout(function () {
elem.style.height = '';
}, timing);
};
// Hide an element
var hide = function (elem) {
// Get the transition timing
var timing = getTiming(elem);
// Give the element a height to change from
elem.style.height = elem.scrollHeight + 'px';
// Set the height back to 0
window.setTimeout(function () {
elem.style.height = '0';
}, 1);
// When the transition is complete, hide it
window.setTimeout(function () {
elem.classList.remove('is-visible');
}, timing);
};
// Toggle element visibility
var toggle = function (elem, timing) {
// If the element is visible, hide it
if (elem.classList.contains('is-visible')) {
hide(elem);
return;
}
// Otherwise, show it
show(elem);
};
Here’s our CSS.
.toggle-content {
display: none;
height: 0;
opacity: 0;
overflow: hidden;
transition: height 350ms ease-in-out, opacity 750ms ease-in-out;
}
.show-fast {
transition: height: 100ms ease-in-out, opacity 300ms ease-in-out;
}
.show-slow {
transition: height: 2000ms ease-in-out, opacity 2500ms ease-in-out;
}
.toggle-content.is-visible {
display: block;
height: auto;
opacity: 1;
}
And here’s the markup to go with it.
<p>
<a class="toggle" href="#example">Toggle Div (normal speed)</a>
</p>
<div class="toggle-content" id="example">
This content reveals at normal speed.
</div>
<p>
<a class="toggle" href="#example-fast">Toggle Div (fast)</a>
</p>
<div class="toggle-content show-fast" id="example-fast">
This content reveals quickly.
</div>
<p>
<a class="toggle" href="#example-slow">Toggle Div (slow)</a>
</p>
<div class="toggle-content show-slow" id="example-slow">
This content reveals at slowly.
</div>
Replacing setTimeout()
with addEventListener()
For this technique, we’ll remove setTimeout()
from our scripts, and replace it with addEventListener()
. Specifically, we’re listening for the transitionend
event on our element.
So in our show()
method, this…
// Once the transition is complete, remove the inline height so the content can scale responsively
window.setTimeout(function () {
elem.style.height = '';
}, timing);
Becomes this…
// Once the transition is complete, remove the inline height so the content can scale responsively
window.addEventListener('transitionend', function () {
elem.style.height = '';
}, false);
And in our hide()
method, this…
// When the transition is complete, hide it
window.setTimeout(function () {
elem.classList.remove('is-visible');
}, timing);
Becomes this…
// When the transition is complete, hide it
window.addEventListener('transitionend', function () {
elem.classList.remove('is-visible');
}, false);
We can also remove our getTiming()
method, since we’re automatically detecting the transition end now.
Here’s a demo of what we’ve got so far.
Removing our event listeners after they run
One thing you might notice about the demo above is that if you open some content, close it, and then try to open it again, it automatically closes itself.
Each click is adding an event listener, so we have multiple listeners running and competing with each other. We need to remove our listener after it runs.
To do that, we’ll give our listener a named function instead of an anonymous one.
// Once the transition is complete, remove the inline height so the content can scale responsively
window.addEventListener('transitionend', function removeHeight () {
elem.style.height = '';
}, false);
Then we’ll remove it with removeEventListener()
after it runs. removeEventListener()
requires a named function and all of the same exact parameters to work properly.
// Once the transition is complete, remove the inline height so the content can scale responsively
window.addEventListener('transitionend', function removeHeight () {
elem.style.height = '';
window.removeEventListener('transitionend', removeHeight, false);
}, false);
We’ll do the same thing under our hide()
method.
// When the transition is complete, hide it
window.addEventListener('transitionend', function removeVisibility () {
elem.classList.remove('is-visible');
window.removeEventListener('transitionend', removeVisibility, false);
}, false);
Here’s a demo with our updated script.
Checking for the transition type
In our simple demo, we know exactly what transitions are happening on the element and can be certain the first one is what we want to trigger our behavior.
In a real website or app, that might not be the case. To make our code more resilient, we should check to see which transition type is happening and only run it if it’s for height
.
To accomplish this, we’ll pass in event
as an argument into our function and check the propertyName
property.
// Once the transition is complete, remove the inline max-height so the content can scale responsively
window.addEventListener('transitionend', function removeHeight (event) {
if (!event.propertyName === 'height') return;
elem.style.height = '';
window.removeEventListener('transitionend', removeHeight, false);
}, false);
Here, we’re checking to see if the propertyName
is height
. If not, we bail on our function. We’ll do the same thing with our hide()
method.
// When the transition is complete, hide it
window.addEventListener('transitionend', function removeVisibility (event) {
if (!event.propertyName === 'height') return;
elem.classList.remove('is-visible');
window.removeEventListener('transitionend', removeVisibility, false);
}, false);
Here’s a demo with the transition type check in place.
Event Prefixes
Older versions of Chrome, Android, Webkit, Safari, and Opera used vendor-prefixed versions of transitionend
(ex. webkitTransitionEnd
).
The affected browsers are many versions old at this point, but if you want to ensure that even someone using a version of Chrome or Opera that’s dozens of versions behind can use your code, there’s a method we can add to determine which event to listen for (shoutout to Diego for sharing this part, as well).
// Get the event name
// Adapted from Modernizr: https://modernizr.com
var whichTransitionEvent = function () {
var el = document.createElement('fakeelement');
var transitions = {
'transition': 'transitionend',
'OTransition': 'oTransitionEnd',
'MozTransition': 'transitionend',
'WebkitTransition': 'webkitTransitionEnd'
}
for (var t in transitions) {
if (el.style[t] !== undefined) {
return transitions[t];
}
}
};
Then in our show()
and hide()
methods, we’ll do this.
// Get transition type
var transition = whichTransitionEvent();
// Once the transition is complete, remove the inline max-height so the content can scale responsively
window.addEventListener(transition, function removeHeight (event) {
if (!event.propertyName === 'height') return;
elem.style.height = '';
window.removeEventListener(transition, removeHeight, false);
}, false);
Given how old the affect browsers are, I’ll personally stick to the standard event names only, but your use case may vary.
Putting it all together
Here’s our completed script (without vendor prefixes).
// Show an element
var show = function (elem) {
// Get the natural height of the element
var getHeight = function () {
elem.style.display = 'block'; // Make it visible
var height = elem.scrollHeight + 'px'; // Get it's height
elem.style.display = ''; // Hide it again
return height;
};
var height = getHeight(); // Get the natural height
elem.classList.add('is-visible'); // Make the element visible
elem.style.height = height; // Update the max-height
// Once the transition is complete, remove the inline max-height so the content can scale responsively
window.addEventListener('transitionend', function removeHeight (event) {
if (!event.propertyName === 'height') return;
elem.style.height = '';
window.removeEventListener('transitionend', removeHeight, false);
}, false);
};
// Hide an element
var hide = function (elem) {
// Give the element a height to change from
elem.style.height = elem.scrollHeight + 'px';
// Set the height back to 0
window.setTimeout(function () {
elem.style.height = '0';
}, 1);
// When the transition is complete, hide it
window.addEventListener('transitionend', function removeVisibility (event) {
if (!event.propertyName === 'height') return;
elem.classList.remove('is-visible');
window.removeEventListener('transitionend', removeVisibility, false);
}, false);
};
// Toggle element visibility
var toggle = function (elem, timing) {
// If the element is visible, hide it
if (elem.classList.contains('is-visible')) {
hide(elem);
return;
}
// Otherwise, show it
show(elem);
};