Building complex animations with React and Framer Motion

Create smooth advanced animations in React with clean and minimal declarative code.

Who doesn’t love fluid animations? They make our user interfaces look more lively and natural. But, as soon as we try to build something more complex than the basic CSS transitions and keyframes can achieve, it tends to get tedious. It doesn’t always have to be like that, especially with modern tools and libraries like Framer Motion. Today, we will try build to one such example i.e. the macOS Dock Magnification animation in React using Framer Motion.

NOTE: This post is primarily structured to demonstrate the composability of various APIs provided by Framer Motion. We will skip over the basics of React and the plain old CSS being used to make the app look decent. Hence, we will assume you know the basics of React and CSS.

A demo of the animation we are creating

If you want to dive straight into the source code, you can head over to the Github repo or the Codesandbox.

Getting started

We will start with a base react app using create-react-app or Codesandbox starter. Next, we will install the dependencies.

yarn add framer-motion @rooks/use-raf

We will be using the @rooks/use-raf hook to run a function using requestAnimationFrame.

So, first let’s get some boilerplate code out of the way.

import React from 'react';

import chrome from './logos/chrome.svg';
import docs from './logos/docs.svg';
import excel from './logos/excel.svg';
import gmail from './logos/gmail.svg';
import photoshop from './logos/photoshop.svg';
import powerpoint from './logos/powerpoint.svg';
import safari from './logos/safari.svg';
import slack from './logos/slack.svg';
import spotify from './logos/spotify.svg';
import steam from './logos/steam.svg';
import vlc from './logos/vlc.svg';

import './styles.css';

const images = [
  chrome,
  docs,
  excel,
  gmail,
  photoshop,
  powerpoint,
  safari,
  slack,
  spotify,
  steam,
  vlc,
];

export default function App() {
  return (
    <div className="app">
      <Dock />
    </div>
  );
}

We will import all SVG files directly and store the source values in an array to render inside our Dock component. We also import a plain old CSS file for styling our mini app.

Tracking the hovering mouse pointer

We will now take a look at our Dock component. We first iterate over all the SVG images and render them using our custom Img component.

import { useMotionValue } from 'framer-motion';

function Dock() {
  const mouseX = useMotionValue(null);

  return (
    <div className="dock">
      <div
        className="icons"
        onMouseMove={(event) => mouseX.set(event.nativeEvent.x)}
        onMouseLeave={() => mouseX.set(null)}
      >
        {images.map((image, index) => (
          <Img src={image} key={index} mouseX={mouseX} />
        ))}
      </div>
    </div>
  );
}

The animation is triggered on hovering of the mouse over the images. In this case, it doesn’t only affect the image being hovered over but also the adjacent ones. So, we need to track the mouse hover behaviour on the parent element of the images i.e. the div with icons class. The mouse hover action can be very frequent and we do not want it to trigger React re-renders. So, we track the state using useMotionValue from Framer Motion instead of using useState. When using the useMotionValue hook, the API for updating or accessing the state is a little different (using get and set methods available on the return value of the hook) because the returned value is similar to the return value of useRef. It doesn’t trigger any React updates because the returned object will persist for the full lifetime of the component.

We attach two event listeners on the div -

  • onMouseMove - This is used to track the current x coordinate of the mouse pointer inside the div
  • onMouseLeave - This is used to reset the x coordinate to null when the mouse pointers moves out of the div.

We also pass the tracked mouseX state value to the Img components.

Transforming the width of the Icons

We have the mouseX value, now we need to translate it into the width values of different images based on their distance from the current x coordinate value of the mouse pointer.

To better understand how it will work, let’s break the effect of distance away from the mouse pointer and related width change into bands.

We have chunked out different bands of distance and corresponding width values. This is a rough representation of how the transform calculations will work. The value labels are not discrete in nature and are only representing the limits of each band.

For example, when an image is within 0 to d/2 unit of distance away from the mouse pointer, it’s width value will be within 2.5x to 1.7x of the base width of the image.

Now, let’s look at how we can represent this logic in code.

const baseWidth = 40;
const distanceLimit = baseWidth * 6;
const beyondTheDistanceLimit = distanceLimit + 1;
const distanceInput = [
  -distanceLimit,
  -distanceLimit / 1.25,
  -distanceLimit / 2,
  0,
  distanceLimit / 2,
  distanceLimit / 1.25,
  distanceLimit,
];
const widthOutput = [
  baseWidth,
  baseWidth * 1.3,
  baseWidth * 1.7,
  baseWidth * 2.5,
  baseWidth * 1.7,
  baseWidth * 1.3,
  baseWidth,
];‌

The distanceInput and widthOutput arrays contain the range of values that we talked about above. We have set the baseWidth value to 40px and the distance upto which we will have a zoomed effect is managed by distanceLimit i.e. 40×640 \times 6 = 240px.

Next, We have to set up the Img component that will update the width of the SVG icons correctly based on the hover state.

import React, { useRef } from 'react';
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import useRaf from '@rooks/use-raf';

function Img({ src, mouseX }) {
  const distance = useMotionValue(beyondTheDistanceLimit);
  const width = useSpring(useTransform(distance, distanceInput, widthOutput), {
    damping: 25,
    stiffness: 250,
  });

  const ref = useRef();

  useRaf(() => {
    const el = ref.current;
    const mouseXVal = mouseX.get();
    if (el && mouseXVal !== null) {
      const rect = el.getBoundingClientRect();

      // get the x coordinate of the img DOMElement's center
      // the left x coordinate plus the half of the width
      const imgCenterX = rect.left + rect.width / 2;

      // difference between the x coordinate value of the mouse pointer
      // and the img center x coordinate value
      const distanceDelta = mouseXVal - imgCenterX
      distance.set(distanceDelta);
      return;
    }

    distance.set(beyondTheDistanceLimit);
  }, true);

  return <motion.img ref={ref} src={src} className="icon" style={{ width }} />;
}

The current distance of the rendered img element from the hovering mouse cursor will be tracked using useMotionValue, similar to the mouseX state in the Dock component. For the initial state, we set the distance value to be 1px more than the limit so that width of the element starts with the base value of 40px.

Now, to map the current distance value to a valid width value for the img element we will make use of the useTransform hook from Framer Motion. This is a very versatile hook that can be used to transform MotionValue type (the return type of useMotionValue) values. We will use it to transform the distance by mapping it from the distanceInput range of values to the widthOutput range of values.

Finally, we pass the transformed MotionValue returned by the useTransform hook to the useSpring hook to animate the width value changes. This hook takes care of interpolating between the old state and new state value of the width with a spring effect managed by the config we pass to it like stiffness, damping, etc. We will use the width value returned by the useSpring hook in the style props to set the width of the img component. Also, you will notice we are using motion.img instead of plain img tag. This is because Framer Motion exports these motion components for every valid HTML and SVG elements that are optimised to achieve smooth 60FPS animations. It is crucial to use these motion components instead of the plain HTML or SVG elements when you are animating properties on these elements using Framer Motion APIs.

Now, for the width to update based on the distance away from the mouse we need to update the tracked MotionValue - distance. Since changes to mouseX are not detected by React (because we are using useMotionValue to track the state), we can’t use anything like useEffect to recalculate the distance when mouseX value changes. So, we use the browser’s requestAnimationFrame API to run our distance updater function, which means the update function will run close to 60times per second. We use a react hook wrapper on top of this API - @rooks/use-raf. We pass our distance updater function as a callback to the hook. We also pass a second argument i.e. the shouldRun flag as true so that our updater function always runs (sort of a game loop, always running).

In our updater callback function, we use the getBoundingClientRect method on the img DOMElement itself to get the center x coordinate value of the element. Then, set the distance value to be the difference between the hovering mouse coordinate values and the center x coordinate value of the img. If the mouseX value is null (the mouse cursor is not over the Dock component), we reset the distance value to be just beyond the effective limit.

Conclusion

Animations are great but often a pain to create in real world applications. In the React ecosystem, creating animations in declarative way used to be a struggle but libraries like react-spring and Framer Motion are changing that. Personally, I am really fond of Framer Motion’s new version (v2) which had its stable release quite recently. I am still actively exploring it and hope to showcase and write more about my experiments with it in the future. I hope this article gave you an idea as to how you can build complex animations by composing the various APIs provided. Please make sure to take a peek at their docs and official examples, they are extremely well designed and thought through.


Sharad Chand picture

Thanks for the write up. I never thought that such a complex & beautiful animation could be written in few lines of code.