Enter & leave transitions

2020-05-04 #javascript #vanilla-js #javascript-framework-diet

Now that we've built a dropdown list, lets add some transitions to create open & close animations.

Broadly speaking, there are two ways to implement enter & leave transitions:

Unfortunately, transition doesn't cut it. Here's what we need for our dropdown list transition:

We can't build this with CSS transitions alone, because there's no way to change the display value before or after the transition happened. I don't like JavaScript animations for these things in JavaScript either, because it couples your JavaScript to CSS and vice-versa.

Best of both worlds

Inspired by Vue and Alpine, we can get the best of both worlds.

We can use CSS to describe the animations across multiple steps, and use JavaScript to trigger those steps.

That means we have some generic "infrastructure" code in JavaScript, but the actual style changes are declared in CSS.

Here's how we'd declare a fade transition using Alpine's transition API:

.fade-enter,
.fade-leave {
transition: all 0.15s ease;
}
 
.fade-enter-start,
.fade-leave-end {
opacity: 0;
}

With a few set class names, we can fully orchestrate various enter & leave transitions. From the Alpine docs:

Want to slide instead of fade? Create a slide transition in CSS instead:

.slide-enter,
.slide-leave {
transition: all 0.15s ease;
}
 
.slide-enter-start,
.slide-leave-end {
transform: translateX(-100px);
}

In our implementation, we're going to orchestrate these classes with two functions: enter to make an element appear, and leave to make it disappear.

// Transition in
enter($('[data-dropdown-list]'), 'fade');
 
// Transition out
leave($('[data-dropdown-list]'), 'fade');

Making elements appear with enter

Let's zoom in on the enter function first. Before we dive into code, lets review what it needs to do:

In short, we're gonna add and remove a bunch of classes.

function enter(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-enter`);
element.classList.add(`${transition}-enter-start`);
 
// Wait until the above changes have been applied...
 
element.classList.remove(`${transition}-enter-start`);
element.classList.add(`${transition}-enter-end`);
 
// Wait until the transition is over...
 
element.classList.remove(`${transition}-enter-end`);
element.classList.remove(`${transition}-enter`);
}

First we'll solve the Wait until the above changes have been applied... problem.

To ensure the classList changes are applied before moving on to removing the *-start class, we can use the browser's requestAnimationFrame function. When you wrap a function in requestAnimationFrame, that function will execute after the browser did it's next repaint, in our case after the element's classList has been visibly modified.

function enter(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-enter`);
element.classList.add(`${transition}-enter-start`);
 
requestAnimationFrame(() => {
element.classList.remove(`${transition}-enter-start`);
element.classList.add(`${transition}-enter-end`);
 
// Wait until the transition is over...
 
element.classList.remove(`${transition}-enter-end`);
element.classList.remove(`${transition}-enter`);
});
}

Because of a Chrome bug, requestAnimationFrame sometimes incorrectly runs in the current frame (derp). The known workaround is wrapping it in another requestAnimationFrame call.

function enter(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-enter`);
element.classList.add(`${transition}-enter-active`);
 
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.classList.remove(`${transition}-enter-start`);
element.classList.add(`${transition}-enter-end`);
 
// Wait until the transition is over...
 
element.classList.remove(`${transition}-enter-end`);
element.classList.remove(`${transition}-enter`);
});
});
}

To keep our enter function clean, we'll extract the requestAnimationFrame logic to its own function that returns a promise, and turn enter into an async function.

async function enter(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-enter`);
element.classList.add(`${transition}-enter-active`);
 
await nextFrame();
 
element.classList.remove(`${transition}-enter`);
 
// Wait until the transition is over...
 
element.classList.remove(`${transition}-enter-active`);
}
 
function nextFrame() {
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
}

Now we need to find a way to wait until the transition is over. To do that, we need to know how long the transition takes. We can determine that by parsing the transition duration with getComputedStyle, which allows us to read the applied CSS values from JavaScript.

const duration = Number(
getComputedStyle(element)
.transitionDuration
.replace('s', '')
) * 1000;

We'll extract this to an helper function similar to the nextFrame function.

async function enter(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-enter`);
element.classList.add(`${transition}-enter-active`);
 
await nextFrame();
 
element.classList.remove(`${transition}-enter`);
 
await afterTransition(element);
 
element.classList.remove(`${transition}-enter-active`);
}
 
function nextFrame() {
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
}
 
function afterTransition(element) {
return new Promise(resolve => {
const duration = Number(
getComputedStyle(element)
.transitionDuration
.replace('s', '')
) * 1000;
 
setTimeout(() => {
resolve();
}, duration);
});
}

And now this should work!

enter($('[data-dropdown-list]'), 'fade');

Making elements disappear with leave

Now that we've learned how to make things appear, making them disappear shouldn't be too hard.

Once again, let's sketch out steps first:

In short, we're gonna add and remove a bunch of classes again.

function leave(element, transition) {
element.classList.add(`${transition}-leave`);
element.classList.add(`${transition}-leave-start`);
 
// Wait until the above changes have been applied...
 
element.classList.remove(`${transition}-leave-start`);
element.classList.add(`${transition}-leave-end`);
 
// Wait until the transition is over...
 
element.classList.remove(`${transition}-leave-end`);
element.classList.remove(`${transition}-leave`);
 
element.classList.add('hidden');
}

Nothing new here. We can fill in the gaps with the same utility functions as before.

async function leave(element, transition) {
element.classList.remove('hidden');
 
element.classList.add(`${transition}-leave`);
element.classList.add(`${transition}-leave-active`);
 
await nextFrame();
 
element.classList.remove(`${transition}-leave`);
 
await afterTransition();
 
element.classList.remove(`${transition}-leave-active`);
element.classList.add('hidden');
}

Applying the transitions to the dropdown

Now that we have our enter and leave functions in place, lets apply them to last lesson's dropdown code.

import { listen, $, enter, leave } from '../util';
 
listen('click', '[data-dropdown-trigger]', openDropdown);
 
function openDropdown(event, dropdownTrigger) {
const dropdownList = $(
'[data-dropdown-list]',
dropdownTrigger.closest('[data-dropdown]')
);
 
if (!dropdownList.classList.contains('hidden')) {
return;
}
 
enter(dropdownList, 'fade');
 
function handleClick(event) {
if (!dropdownList.contains(event.target)) {
leave(dropdownList, 'fade');
 
window.removeEventListener('click', handleClick);
}
}
 
window.requestAnimationFrame(() => {
window.addEventListener('click', handleClick);
});
}