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
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
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;
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;
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 Cell
s. 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;
We should now have a grid that responds to the user's browser width!
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} />
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>;
};
// ...
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]);
// ...
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>
);
// ...
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!
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;
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`;
// ...
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 }}>
// ...
We should now have a spotlight that follows our mouse cursor!
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]);
// ...
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);
};
// ...
Now we pass the opacity value to the style
prop of our Grid
's Container
:
// ...
<Container
columns={columns}
style={{
opacity,
WebkitMaskPosition,
}}
>
// ...
The grid should now fade in and out based on the user's mouse velocity. This part really brings the animation together.
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!