How to make an advanced pointer animation (TS React and Framer Motion)

arielbk - Sep 8 '22 - - Dev Community

I found the Pointer blog through Product Hunt, and I was really impressed with the background animation there. It's intricate, but because it only appears when the mouse is moved it's still low key and minimal.

I wanted to see how it was made, so I experimented and tried to reverse engineer it. This is what we'll be building today: demo.

I learnt a lot about Framer Motion along the way, and finally got a chance to try out Vite. It's fast.

I figured this is a great opportunity to pass on the knowledge. Here are some of the things you'll learn:

  • React
  • Vite
  • TypeScript
  • Emotion for CSS-in-JS
  • Advanced animation with Framer Motion

Setup

We'll start by scaffolding our project with Vite:



# npm
npm create vite@latest pointer-animation -- --template react-ts

# yarn
yarn create vite pointer-animation --template react-ts


Enter fullscreen mode Exit fullscreen mode

Follow the commands at the the end to install dependencies and run the project in development mode, then install some further dependencies:



# npm
npm install @emotion/react @emotion/styled framer-motion

# yarn
yarn add @emotion/react @emotion/styled framer-motion


Enter fullscreen mode Exit fullscreen mode

The grid

Create a components folder inside of src where we can start to build our components, and inside that create a Cell.tsx component with the following:



import styled from '@emotion/styled';

export const CELL_SIZE = 60;

const Container = styled.div`
  width: ${CELL_SIZE}px;
  height: ${CELL_SIZE}px;
  border: 1px dashed #555;
  color: #777;
  display: flex;
  justify-content: center;
  align-items: center;
  user-select: none;
`;

const Cell: React.FC = () => {
  return <Container></Container>;
};

export default Cell;



Enter fullscreen mode Exit fullscreen mode

This is our grid cell with some basic styling. Notice that there is a text that we will animate later. The CELL_SIZE is a constant at the top of the file so we can easily adjust it.

Next up, let's create a Grid.tsx component:



import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import Cell, { CELL_SIZE } from './Cell';

const Container = styled(motion.div)<{
  columns: number;
}>`
  position: absolute;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  display: grid;
  grid-template-columns: repeat(${(props) => props.columns}, 1fr);
`;

function Grid() {
  const [columns, setColumns] = useState(0);
  const [rows, setRows] = useState(0);

  // determine rows and columns
  useEffect(() => {
    const calculateGrid = () => {
      const columnCount = Math.ceil(window.innerWidth / CELL_SIZE);
      setColumns(columnCount);
      const rowCount = Math.ceil(window.innerHeight / CELL_SIZE);
      setRows(rowCount);
    };
    // calculate the grid on load
    calculateGrid();
    // recalculate grid on resize
    window.addEventListener('resize', calculateGrid);
    // cleanup
    return () => {
      window.removeEventListener('resize', calculateGrid);
    };
  }, []);

  return (
    <Container columns={columns}>
      {Array.from({ length: columns * rows }).map((_, i) => (
        <Cell key={i} />
      ))}
    </Container>
  );
}

export default Grid;



Enter fullscreen mode Exit fullscreen mode

Let's break this down.

We style our Container element so that it covers the entire viewport (100vw and 100vw) and is absolutely positioned to the top left.

We use CSS grid here, and determine the number of columns based on a prop from the main Grid component.

The Grid component holds the number of columns and rows in state. That's calculated inside the useEffect which runs when the component mounts.

The calculateGrid function determines the number of columns and rows we'll need to cover the entire screen with Cells. We call this once (so it runs when the component mounts) and add it to an event listener. If the user resizes the screen, the number of rows and columns will be recalculated.

Finally, we render Cell components based on the number of columns and rows with a little trick using Array.from.

Let's remove some boilerplate and render the Grid component inside of App.tsx:



import './App.css';
import Grid from './components/Grid';

function App() {
  return (
    <Grid />
  );
}

export default App;



Enter fullscreen mode Exit fullscreen mode

We should now have a grid that responds to the user's browser width!

Static arrows

Pointers

We have our static grid and arrows, and now we want them to point towards the mouse cursor.

Add the following to the Grid component:



import { animate, motion, useMotionValue } from 'framer-motion';

// ...

// mouse position
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);

// handle mouse move on document
useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
        // animate mouse x and y
        animate(mouseX, e.clientX);
        animate(mouseY, e.clientY);
    };
    // recalculate grid on resize
    window.addEventListener('mousemove', handleMouseMove);
    // cleanup
    return () => {
        window.removeEventListener('mousemove', handleMouseMove);
    };
}, []);

// ...

<Cell key={i} mouseX={mouseX} mouseY={mouseY} />


Enter fullscreen mode Exit fullscreen mode

We have new state here that will store the coordinates of the mouse cursor as motion values. This means we can pass the values to Framer Motion to handle animation.

We've added an event listener to the window object that updates the motion value using an animate function whenever the mouse moves. We pass that value down to every cell as a prop.

Now, on to the Cell component:



import { motion, MotionValue, useTransform } from 'framer-motion';
import { useState, useRef } from 'react';

// ...

interface CellProps {
  mouseX: MotionValue<number>;
  mouseY: MotionValue<number>;
}

const Cell: React.FC<CellProps> = ({ mouseX, mouseY }) => {
  const [position, setPosition] = useState([0, 0]);
  const ref = useRef<HTMLDivElement>(null);

    return <Container ref={ref}></Container>;
};

// ...


Enter fullscreen mode Exit fullscreen mode

We are receiving the mouse coordinate props here and typing them as motion values that hold a number.

We want to determine the position of each cell. To do that, we've added state to hold coordinates, and a React ref we can use to reference the DOM element of the cell.

Let's add a useEffect that will set the centre position of the cell to state:



import { useState, useRef, useEffect } from 'react';

// ...

useEffect(() => {
    if (!ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    // center x coordinate
    const x = rect.left + CELL_SIZE / 2;
    // center y coordinate
    const y = rect.top + CELL_SIZE / 2;
    setPosition([x, y]);
}, [ref.current]);

// ...


Enter fullscreen mode Exit fullscreen mode

Every cell now knows it's own coordinates, and is being passed the coordinates of the mouse cursor.

We need to determine the angle of the line from the current cell to the mouse cursor:



// ...

const direction = useTransform<number, number>(
    [mouseX, mouseY],
    ([newX, newY]) => {
        const diffY = newY - position[1];
        const diffX = newX - position[0];
        const angleRadians = Math.atan2(diffY, diffX);
        const angleDegrees = Math.floor(angleRadians * (180 / Math.PI));
        return angleDegrees;
    }
);

// ...

return (
  <Container ref={ref}>
    <motion.div style={{ rotate: direction }}></motion.div>
  </Container>
);

// ...


Enter fullscreen mode Exit fullscreen mode

We do this with the useTransform hook from Framer Motion. This takes in motion values, transforms them, and gives back a new motion value.

The crucial part here is the Math.atan2 method that we use to find the angle between the centre of the cell and the mouse cursor. We convert that from radians to degrees so we can pass it directly to our arrow.

The arrow is wrapped in a motion.div and we pass it our new motion value to animate.

The arrows should now be following the mouse cursor!

Moving arrows

Spotlight

We already have a pretty cool effect, but the next touches will really make it impressive.

First off, we'll add the following styles to our Grid's styled Container:



mask-image: radial-gradient(
    300px 300px,
    rgba(0, 0, 0, 1),
    rgba(0, 0, 0, 0.4),
    transparent
);
mask-repeat: no-repeat;


Enter fullscreen mode Exit fullscreen mode

We should now see that the middle of our arrow grid is lit up and the outside is darkened. We want this centre mask to follow our mouse.

Add the following underneath our mouse coordinate motion values in the Grid component:



import {
  animate,
  motion,
  useMotionTemplate,
  useMotionValue,
  useTransform,
} from 'framer-motion';

// ...

const centerMouseX = useTransform<number, number>(mouseX, (newX) => {
    return newX - window.innerWidth / 2;
});
const centerMouseY = useTransform<number, number>(mouseY, (newY) => {
    return newY - window.innerHeight / 2;
});
const WebkitMaskPosition = useMotionTemplate`${centerMouseX}px ${centerMouseY}px`;

// ...


Enter fullscreen mode Exit fullscreen mode

By default, our mask will be at the centre of the screen and its placement will be anchored from there. We transform the current mouse position so that the coordinates are from the centre of the screen.

We then create a motion template value with a hook from Framer Motion, and we can add that to our Grid's Container component like so:



// ...

<Container columns={columns} style={{ WebkitMaskPosition }}>

// ...


Enter fullscreen mode Exit fullscreen mode

We should now have a spotlight that follows our mouse cursor!

Spotlight arrows

Velocity Fade

For this final touch we'll need to do some gymnastics to make it smooth, so be warned.

Under our previous mouse motion values in the Grid component, add the following:



import {
  animate,
  motion,
  useMotionTemplate,
  useMotionValue,
  useTransform,
  useVelocity,
} from 'framer-motion';

// ...

// eased mouse position
const mouseXEased = useMotionValue(0);
const mouseYEased = useMotionValue(0);
// mouse velocity
const mouseXVelocity = useVelocity(mouseXEased);
const mouseYVelocity = useVelocity(mouseYEased);
const mouseVelocity = useTransform<number, number>(
    [mouseXVelocity, mouseYVelocity],
    ([latestX, latestY]) => Math.abs(latestX) + Math.abs(latestY)
);
// map mouse velocity to an opacity value
const opacity = useTransform(mouseVelocity, [0, 1000], [0, 1]);

// ...


Enter fullscreen mode Exit fullscreen mode

We create a motion value that again holds the mouse coordinates, except this time they are going to be eased. This is what will give us the fading out effect in the end.

We determine the velocity of the mouse cursor by combining the velocity of our x and y coordinates with Framer Motion's useVelocity hook.

From there, we map the mouse velocity (I found a range of 0 to 1000 worked well) to an opacity value between 0 and 1.

We're almost there. We just need to animate the eased mouse coordinates when the mouse moves. Our handleMouseMove function inside of Grid should look like the following:



import {
  animate,
  AnimationOptions,
  motion,
  useMotionTemplate,
  useMotionValue,
  useTransform,
  useVelocity,
} from 'framer-motion';

// ...

const handleMouseMove = (e: MouseEvent) => {
    // animate mouse x and y
    animate(mouseX, e.clientX);
    animate(mouseY, e.clientY);
    // animate eased mouse x and y
    const transition: AnimationOptions<number> = {
        ease: 'easeOut',
        duration: 1,
    };
    animate(mouseXEased, e.clientX, transition);
    animate(mouseYEased, e.clientY, transition);
};

// ...


Enter fullscreen mode Exit fullscreen mode

Now we pass the opacity value to the style prop of our Grid's Container:



// ...

<Container
    columns={columns}
    style={{
        opacity,
        WebkitMaskPosition,
    }}
>

// ...


Enter fullscreen mode Exit fullscreen mode

The grid should now fade in and out based on the user's mouse velocity. This part really brings the animation together.

Velocity faded arrows


Well done for making it this far!

There are techniques here that you can get creative with. It was a learning experience for me, and I'm looking forward to diving deeper.

You can check out the final project repo to see the final code, and again here's a link to the demo.

Did you have any problems along the way? Are there parts you think could be improved? Did you manage to use the techniques to create something else?

I'd love to hear about it in the comments!

. . . . . . . .