Create an animated Vue 3 component when scrolling into view
These days animation is used a lot in websites. Especially elements that animate when they enter the view have gained massive popularity. It isn’t that difficult to understand why as it looks awesome. The landing page of GitHub is an excellent example of how cool this can be.
“So how can I build my own?”
There are two techniques that, when combined, will allow us to create a reusable component that can be used to animate any content once it enters into the view. So let’s get started!
The setup
You probably have your own project that you’re currently working on, but for this example I’m using the basic Vue 3 project setup using the create Vue app. We will add most of our code to App.vue
so you don’t have to switch between components and files too much. The other will be our animated component.
Creating the component
First off, we need to create our component. We’ll create a new file under the components
directory called AnimatedComponent.vue
. In this file we’ll add the template, the functionality and the styling.
The template
<template>
<div ref="target">
<transition :name="animationType">
<div v-if="animate" class="animated-component">
<slot />
</div>
</transition>
</div>
</template>
As you can see, we don’t need much HTML for this component. It consists of an element with ref="target"
that will be used to determine if the element is inside our view. I’ll get back to this later.
Next a Vue transition
component is used. This is part of Vue itself so it doesn’t need to be imported or anything. What this does is add certain classes to the element inside it to trigger the animations. We’ll get back to this some more in a while.
Inside the transition an element has to be present that uses v-if
or v-show
(or a custom directive, see the bonus chapter at the end of the story). The transition component uses this to determine whether it has to trigger it’s animation states. Without it, nothing will happen. See Vue’s documentation for more information about this.
Lastly, a Vue slot
is added. This will insert the content that we’ll add in the App.vue
code between the <animated-code>
tags. If you’re not yet familiar with Vue slots, you should definitely check this out as this is a very useful and powerful bit of functionality in the Vue framework.
The functionality
The above template doesn’t do anything useful at this moment, so let’s remedy that by adding some functionality. We’ll add a script tag containing the Vue 3 component code above the template.
<script setup lang="ts">
import { onMounted, ref } from 'vue';
withDefaults(defineProps<{ animationType?: string }>(), { animationType: 'fade' });
const target = ref<Element>();
const animate = ref<boolean>(false);
const observer = new IntersectionObserver(
([entry]) => {
animate.value = entry.isIntersecting;
},
{
threshold: 0.5
}
);
onMounted(() => {
observer.observe(target.value as Element);
});
</script>
This component takes a property called animationType
which is an optional string that defaults to the “fade” animation. This will be added later in the CSS. It is surrounded by a withDefaults
function that sets a default value to the optional property. Documentation about this can be found here.
Next we’ve added an IntersectionObserver
. Simply put; this watches the element defined with the ref="target"
in the template and watches if it is inside the view. The threshold
option determines that at least 50% of the element needs to be inside the view in order to trigger the callback function. The callback function in turn sets a boolean variable in order to trigger the animation.
For more information about how the observer works you can check out my previous story and/or the Mozilla developer documentation.
Adding the styling
Now that we have added the functionality that will trigger the animation of our component we have to add the actual CSS animations. In this example we’re adding two different animations: fade
and zoom
.
<style scoped>
.animated-component.fade-enter-from,
.animated-component.zoom-enter-from {
transition: none;
}
/* Fade animation */
.fade-enter-active,
.fade-leave-active {
transition: opacity 300ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Zoom animation */
.zoom-enter-active,
.zoom-leave-active {
transition: transform 300ms ease;
}
.zoom-enter-from,
.zoom-leave-to {
transform: scale(0.9);
}
</style>
The transition component adds classes for different states within the animation. This is based on the name property that is provided. For example; zoom-enter-active
is used when the component is entering the view when the component has the property name
set to “zoom”. You can check out the different states in the Vue documentation.
Adding the component to the application
Now that we’ve created our component, we can add it to the page so we can see it in action.
Import the component
The App.vue
does not contain our newly created component yet. So we’ll import it in the script tag in App.vue
.
<script setup lang="ts">
import AnimatedComponent from '@/components/AnimatedComponent.vue';
import { RouterLink, RouterView } from 'vue-router';
import HelloWorld from './components/HelloWorld.vue';
</script>
Add the component to the template
Next we’ll use the imported component in the template. This will use the <animation-component>
tags in which you can place your own content. For this example we’ll add an element containing some Lorem Ipsum as a placeholder.
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
<div class="demo-container">
<animated-component>
<div class="demo">
Lorem ipsum dolor...
// The rest of the text is omitted for readability
</div>
</animated-component>
<animated-component animation-type="zoom">
<div class="demo">
Lorem ipsum dolor...
// The rest of the text is omitted for readability
</div>
</animated-component>
</div>
</template>
As you can see, the first component has no animation type attribute provided to it, which causes the default value “fade” to be used. The second one has the type “zoom”, triggering a different animation.
We can actually test the implementation now by going to the page and refreshing. The components should now fade and zoom in respectively. But as they have no styling at all it may be less visible and just looks bad.
Add some styling to the content
To make the animations easier to spot we’ll add some styling to the content. We’ve already added the class “demo” to the component’s content which we’ll use here. By adding the following styling to the existing style
tag the components will be easier to spot.
.demo {
color: white;
max-width: 600px;
border: 2px solid #9dd3ba;
padding: 2rem;
margin: 2rem auto;
background-color: #42b983;
}
It’s not the prettiest of components but it will do the trick for demonstration purposes. When you refresh the page you’ll see the components being animated, showing us that the functionality works.
Testing if it works when scrolling into view
As the title said we want this to work when the components scroll into view we’ll have to adjust the styling of the page a bit to make sure it scrolls. In this example we’ve added a container element around the two animated components. We just need to add some padding to the container. So in the same style tag, add the following CSS:
.demo-container {
min-height: 100vh;
padding: 50vh 0;
}
Now our testing page will have the components outside the initial view and when scrolling down, you’ll see the elements being animated into view!
Conclusion
That’s it! We’ve created a reusable component that will animate any content once it is inside the view. This can be used for images, video’s and any other content that you can think of.
Getting the code
The result code of this tutorial can be found in GitHub. This also includes the code from the below bonus chapter.
Bonus: Keeping the space occupied
A downside of the default v-if
and v-show
functionality is that they remove the element from the DOM or set the display to none. In both cases the size of the page is impacted if you don’t have a containing element that keeps the space intact. This can cause the page to “jump” as the animated component is being shown or hidden.
Creating a custom directive
In order to fix this caveat, we can create a custom directive that does not remove the element from the view but instead sets the visibility
of the element. This means the space in the page will still be occupied by the element so the structure is not impacted.
In main.ts
we’ll create the directive and add it to the application so we can use it in our template.
import { createPinia } from 'pinia';
import {
createApp, type Directive, type DirectiveBinding, type VNode
} from 'vue';
import './assets/main.css';
import App from './App.vue';
import router from './router';
export const appear: Directive = {
beforeMount(element: HTMLElement) {
element.style.visibility = 'hidden';
},
updated(
element: HTMLElement,
binding: DirectiveBinding<boolean>,
node: VNode
) {
if (!binding.value === !binding.oldValue || null === node.transition) {
return;
}
if (!binding.value) {
node.transition.leave(element, () => {
element.style.visibility = 'hidden';
});
return;
}
node.transition.beforeEnter(element);
element.style.visibility = '';
node.transition.enter(element);
}
};
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.directive('appear', appear);
app.mount('#app');
This directive uses some Vue states in order to first set the visibility to “hidden” as an initial value. Then it checks if it needs to add or remove the value in the updated state. Lastly the directive is added to the app variable that was created by the createApp()
function so it will be available in the templates.
Use the directive
The last thing is to replace the used directive in AnimatedComponent.vue
.
<template>
<div ref="target">
<transition :name="animationType">
<div v-appear="animate" class="animated-component">
<slot />
</div>
</transition>
</div>
</template>
Now the component will show or hide while leaving the rest of the page unaffected. Cool, right?