Animating Elements In From A Mouse-Event Location In Vue.js 2.5.21
Over the weekend, I re-created the Pandora Radio station list animation using Vue.js 2.5.21. Only, I didn't really "do" anything; because, as it turns out, this list "move" animation is supported right out of the box in Vue.js. To be honest, this kind of blew me away. So, I wanted to experiment with other animation features. Specifically, I wanted to see if I could animate a list item's "enter" transition based on the {x,y} coordinates of a Click event. Vue.js provides JavaScript animation hooks that make this possible; but, it took a little bit of elbow grease to get the transition working the way I wanted it.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
One of the cool things about "enter" and "leave" animations in Vue.js 2.5.21 is that Vue provides both CSS and JavaScript hooks into the transition configurations. And, these two sets of hooks are not mutually exclusive. You can have CSS-only hooks; JavaScript-only hooks; or, use the two types of hooks in conjunction.
Personally, I want to push as much of my transition and animation configuration into the CSS so that can I reduce the number of dependencies in the application (thereby reducing size and complexity). If the browser can drive the animations using native technologies, I'd much rather leverage the out-of-the-box solution instead of pulling in something like GreenSock or TweenMax. But, that's just my personal preference - your mileage may vary.
That said, in order to animate an item's "enter" phase from the {x,y} coordinates of a click event, I had to use both the CSS hooks and the JavaScript hooks. I used the CSS hooks to define the duration, properties, and final state of the animation. Then, I used the .enter() JavaScript hook in order to define the initial position of the item.
Ideally, I would have liked to use the .beforeEnter() JavaScript hook to configure the initial styles of the new item. However, in this case, I needed to find the bounding-rectangle of the newly-inserted item; and, as it turns out, the item has not yet been injected into the DOM (Document Object Model) when the .beforeEnter() hook is invoked. As such, I had to perform my initial calculation in the .enter() hook.
The .enter() hook is invoked after the item is inserted into the DOM; however, there's only one more hook fired after the .enter() method, which fires when the animation has completed. As such, I need to jump through a few hoops in order to setup the animation and then "restart it" (for lack of a better phrase) all within the one .enter() invocation.
To do this, I have to take the following steps inside the .enter() hook:
- Calculate the initial position (translation) of new item.
- Apply "transform" styles.
- Disable transition duration.
- Force browser to repaint, applying aforementioned translation.
- Nullify initial position and transition overrides.
Here's the code. The user can click anywhere in the viewport and a new item will be transitioned in from the click coordinates:
<style scoped src="./app.component.less" />
<style scoped lang="less">
.item {
&-enter {
opacity: 0.0 ;
// NOTE: Since the initial value of the "transform" property is data-driven
// based on the Vue-state, we need to calculate the transform in the .enter()
// hook of the Vue class.
}
&-enter-active {
// NOTE: The first duration listed below is the one that will be consumed by
// Vue.js when defining the overall animation time. As such, we have to list
// the opacity properties second as they are going to be shorter.
transition-duration: 300ms, 200ms ;
transition-property: transform, opacity ;
transition-timing-function: ease ;
}
&-enter-to {
opacity: 1.0 ;
transform: translateX( 0px ) translateY( 0px ) ;
}
}
</style>
<template>
<div class="app">
<!-- NOTE: Transition-Group will render our UL element. -->
<transition-group
name="item"
tag="ul"
@enter="enter"
class="items">
<li
v-for="item in items"
:key="item.id"
class="item">
<br />
</li>
</transition-group>
<div @click="addFromEvent( $event )" class="click-capture">
<!--
I provide a full-viewport overlay to handle click-events so that we don't
have to much around with binding event-handlers on the WINDOW object.
-->
</div>
</div>
</template>
<script>
export default {
data() {
return({
items: [],
mostRecentClickCoordinates: null
});
},
methods: {
// I add a new item based on the given mouse-click event.
addFromEvent( event ) {
this.items.push({
id: Date.now()
});
// When Vue applies the "enter" animation for the above item, we want to
// animate it in from the current event's click-location. As such, we
// have store the coordinates for use in the subsequent animation hooks.
this.mostRecentClickCoordinates = {
x: event.clientX,
y: event.clientY
};
},
// I work IN CONJUNCTION with the CSS-based animation to manage the enter
// phase of the new item rendering. In particular, I calculate the initial
// location of the item.
// --
// NOTE: We don't need the done() argument since we are defining the
// transition timing in the CSS.
enter( element ) {
// Calculate the initial transform to position the item under the user's
// cursor (based on the stored mouse-click event).
var clickX = this.mostRecentClickCoordinates.x;
var clickY = this.mostRecentClickCoordinates.y;
var rect = element.getBoundingClientRect();
var deltaX = ( clickX - rect.left - ( rect.width / 2 ) );
var deltaY = ( clickY - rect.top - ( rect.height / 2 ) );
// Style the item position.
element.style.transform = `translateX( ${ deltaX }px ) translateY( ${ deltaY }px )`;
// When we define the initial position of the item, we have to LOCALLY
// DEACTIVATE the transition property otherwise the item will never make
// it from the static position out to the transformed position.
element.style.transition = "none";
// By default, the browser attempts to optimize updates by "chunking" CSS
// changes. As such, we have to force the browser to repaint in order to
// apply the above styles before we nullify them below.
this.__force_paint__ = document.body.offsetHeight;
// At this point, the browser has rendered the new item at the
// transformed location without any transition timing. And, now that we
// have configured the initial position, we can nullify the LOCAL STYLE
// of the item so that the browser will transition the CSS based on the
// "item-enter-to" class.
element.style = null;
}
}
};
</script>
As you can see, in order to transition the item in from the click coordinates, I have to store the coordinates of the user's most recent click. I then use said click coordinates inside of the .enter() JavaScript hook in order to calculate the relative translation styles.
Once the translation styles are applied, I force the browser to repaint. This will render the already-inserted DOM element just below the user's cursor. However, as this is being done with an element-local style property, it will override the CSS properties in our CSS-based class hooks. As such, I have to then strip out the element-localy style so that the browser will natively transition the CSS properties back to the state defined in my ".item-enter-to" class.
Now, if we run this Vue.js application in the browser and I start clicking around the window, we get the following output:
As you can see, the new items are transitioning-in from my mouse location to their natural location within this flexbox-based layout.
Playing with the element animations in Vue.js 2.5.21 makes me very nostalgic for the simple CSS-based animations in Angular.js. In fact, I performed a very similar experiment in AngularJS three-years ago. I love pushing as much of the animation into CSS as possible as I believe this limits the complexity of the application. But, I also love that Vue.js 2.5.21 provides JavaScript hooks that can work alongside and in conjunction with the CSS hooks when you need even more granular control over the "enter" and "leave" animations.
Want to use code from this post? Check out the license.
Reader Comments
Wrong video embedded ??
@Chris,
Ugggg, this is the second time I've done that. Much frustrating!
@Chris,
Fixed! Thanks for catching that :D
@Ben
No worries. Thx for another great demo walk through.
@Chris,
It is always a pleasure, good sir!
How can I hide the item as soon as it arrives?
I mean I want to see the item moves to a point and as soon as it gets to the point it disappears