Enter & leave transitions
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:
- Just use the
transitionCSS properties - Animations with JavaScript (either by modifying CSS properties, with the web animation API, or with third-party animation libraries)
Unfortunately, transition doesn't cut it. Here's what we need for our dropdown list transition:
- When the dropdown opens, set it's
displayproperty toblock, then set transitionopacityfrom0to1 - When the dropdown closes, transition
opacityfrom1to0, then set it'sdisplayproperty tonone
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:
-
*-enter: Applied during the entire entering phase. -
*-enter-start: Added before element is inserted, removed one frame after element is inserted. -
*-enter-end: Added one frame after element is inserted (at the same time enter-start is removed), removed when transition/animation finishes. -
*-leave: Applied during the entire leaving phase. -
*-leave-start: Added immediately when a leaving transition is triggered, removed after one frame. -
*-leave-end: Added one frame after a leaving transition is triggered (at the same time leave-start is removed), removed when the transition/animation finishes.
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 inenter($('[data-dropdown-list]'), 'fade'); // Transition outleave($('[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:
- Since the element isn't visible yet, remove
display: none(we'll use ahiddenclass to control this) - Add the
fade-enterclass - Add the
fade-enter-startclass - Remove the
fade-enter-startclass. Since we just added it, we need to ensure the previous changes have been rendered before we remove it, otherwise it's a no-op. - Add the
fade-enter-endclass - When the transition is finished, remove the
fade-enter-endandfade-enterclasses
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:
- Add the
fade-leaveclass - Add the
fade-leave-startclass - Remove the
fade-leave-startclass. Since we just added it, we need to ensure the previous changes have been rendered before we remove it, otherwise it's a no-op. - Add the
fade-leave-endclass - When the transition is finished, remove the
fade-leave-endandfade-leaveclasses - Finally, set the
displayproperty tononeby adding ahiddenclass
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); });}