Let's build Vue directives - Plug and play Motion Design with vMotion by Tobias Quante

Let's build Vue directives - Plug and play Motion Design with vMotion

Tobias Quante

Tobias Quante

Table of contents

TL: DR - take me to the code
-> Vue 3 Code Sandbox

The idea of motion design has been around since the 1950s. Its primary advantage over the static counterpart is user engagement. Our eyes evolved to pay particular attention to moving objects. Motion design aims to do precisely that - captivate your user's eye using animations.

I've written about a directive with a similar goal before. This time, we won't stop at a simple ripple effect but animate whole parts of your web app.

Reasons for a Vue directive

This type of design pattern uses a browser's Observer API. To observe an element, you need direct access to it. Directives are a straightforward way to provide it. In a nutshell, we use a directive because it

  • is easy to add to an element
  • while remaining configurable
  • and encapsulates the responsibility to create a new IntersectionObserver

Setup a Vue 3 Project with Vite

We'll use Vite to spin up a basic application. You can alternatively use Vue CLI.

Change into a directory of your choice and type:

# 1: Init your project
npm init vite@latest # using Vite with npm
# yarn create vite   # using Vite with yarn
# vue create .       # using Vue CLI

# 2: Change into the created folder and start the dev server
cd vite-project
npm install
npm run dev

Make the app object available for directive registration

Before registering our custom directives, let's make an adjustment in Vue's main file. It uses 'createApp' on the fly, but we need the created app object to register components.

This step is optional, you could also chain .directive() as part of the createApp bootstrapping process.
// Inside main.js: Change this 
import { createApp } from 'vue'
import App from './App.vue'


// ------

// to 
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// ... register directives here


With app.directive(directiveName, directiveFunction), we're now able to register our own directives everywhere in the app.

Create the motion directive

We will use the Intersection Observer API to track elements going in and out of view. It allows us to track DOM elements and whether they intersect with the viewport. Based on that information, we'll add the relevant animations.

We'll then use the standard Vue 3 'mounted' lifecycle to apply the directive. Let's start by creating a '/directives' folder in the '/src' of our project and name it 'vMotion.js'.

The parent function

In this file, add the following code:

// Adjust these as per your requirement
const defaultFromValue = 'right' // or 'left';
const defaultSpeedValue = 0.5;
const defaultTheresholdValue = 0.5;

// Handle the intersection event
function handleIntersection(el, observer, from) {
	const { isIntersecting } = observer;
	// function slideInElementFromRight() { ... }

	// function slideInElementFromLeft() { ...	}

	if (from === 'right') {

	if (from === 'left') {

const vMotion = {
	mounted: (el, binding) => { /* ... */ }

export default vMotion;

We created a few default variables for our directive's behavior. These are meant to be customizable using the directive's binding. We also declared our handler function and a 'vMotion' - object. It represents our directive. Let's start by writing the handler and then add it to our directive's 'mounted' lifecycle.

Define the animation style

An intersection observer checks whether the observed element is inside the current viewport. If the check is positive, the registered callback function fires. It will check whether the element is inside or outside the current viewport. Depending on the outcome, the relevant animation is then played.

Let's first write out the two animation functions. Inside the 'handleIntersection' context, add these two functions:

  function slideInElementFromRight() {
    if (isIntersecting) {
      el.style.opacity = 1;
      el.style.transform = "translateX(0px)";
    } else {
      el.style.opacity = 0;
      el.style.transform = "translateX(100px)";

  function slideInElementFromLeft() {
    if (isIntersecting) {
      el.style.opacity = 1;
      el.style.transform = "translateX(0px)";
    } else {
      el.style.opacity = 0;
      el.style.transform = "translateX(-100px)";

Register the animation

The callback function is in place. We can now create a new IntersectionObserver and register the animations.

Change the 'vMotion' directive to look like this:

const vMotion = {
  mounted: (el, binding) => {
    el.style.transition = `all ${binding.value?.speed || defaultSpeedValue}s`;

    // Create a new IntersectionObserver for the element
    const observer = new IntersectionObserver(
      // Callback function to handle intersection event
      (entries) =>
          binding.value?.from || defaultFromValue

      // Options for the Observer
        threshold: binding.value?.thereshold || defaultTheresholdValue

  • We define a dynamic transition for the element to smooth in and out of sight
  • We create an observer instance and register our 'handleIntersection' callback-function
  • We add a threshold option to determine how much of the element has to be in the viewport for the function to execute
  • We call the 'observe' method to start monitoring the element's position

Register the directive in your App file

We're almost done. The only thing left to do is to register the directive and give it a try:

Inside the 'main.js' file, register the directive:

import { createApp } from 'vue'
import App from './App.vue'
import vMotion from './directives/vMotion'

const app = createApp(App)

app.directive('motion', vMotion)


Use the directive on the template

Now try it out yourself. Add the 'v-motion' directive to an element of your choice and see how it behaves. You can (and should) try the code sandbox for this project to see some examples.

The directive's binding for this project can be customized like this:

  speed: 5        // In seconds, the bigger, the slower
  threshold: 0.25 // 0 to 1, the bigger, the longer element stays in view
  from: 'right'   // or 'right'. Determines from where the element moves in
Share this article