Building an Animated Pattern

Pattern animation built using useMotionValue, useSpring and useTransform from Motion.

Before diving into how this component is built, I think it's important to understand what the three Motion hooks actually do. I also made this component so feel free to check out the code!

useMotionValue

You can think of this hook as a special variable that can change without re-rendering your component every time it changes.

const x = useMotionValue(0);
 
// Update the value
x.set(100);
 
// Read the value
const currentX = x.get();

This comes in very handy for this interaction in particular, because when tracking mouse position, the value changes 60+ times per second. Re-rendering your entire component that often would be slow and not very performant.

useTransform

This hook automatically calculates a new value based on other motion values.

const x = useMotionValue(0);
 
// When x changes, rotate changes automatically
const rotate = useTransform(x, [0, 100], [0, 360]);
// x = 0  → rotate = 0
// x = 50 → rotate = 180
// x = 100 → rotate = 360

It creates a reactive chain where when the input changes the output gets updated automatically.

useSpring

This hook adds spring animations to any value change.

const x = useMotionValue(0);
const xSpring = useSpring(x, {
  mass: 0.1,
  stiffness: 200,
  damping: 30,
});
 
x.set(100);
Creating the Pattern

The pattern itself is just a simple grid made out of individual shapes. The shapes could be anything and they could be laid out in any way you want.

<div className="grid-cols-24 grid gap-5">
  {Array.from({ length: 24 * 6 }, (_, index) => (
    <Shape key={index} />
  ))}
</div>
Tracking Mouse Position

First, we need to track the user's cursor position. To do this, we create two motion values that track the x and y coordinates of the cursor.

const Pattern = () => {
  const mouseX = useMotionValue(-Infinity);
  const mouseY = useMotionValue(-Infinity);
 
  const handleMouseMove = (e: React.MouseEvent) => {
    mouseX.set(e.clientX);
    mouseY.set(e.clientY);
  };
 
  return <div onMouseMove={handleMouseMove}>{/* shapes will go here */}</div>;
};

We start with the value of -Infinity. This indicates that there is no mouse position yet as we only want to track the position when the cursor enters the pattern container.

Calculating Distance and Angle

To calculate the distance and the angle from the cursor, each individual shape needs to figure out how far away the cursor is and what angle it should rotate to point toward it.

const Shape = ({ mouseX, mouseY }) => {
  const ref = useRef(null);
 
  const rotate = useTransform([mouseX, mouseY], () => {
    const rect = ref.current.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;
 
    const deltaX = mouseX.get() - centerX;
    const deltaY = mouseY.get() - centerY;
    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
 
    if (distance > 100) return 0;
 
    const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) + 90;
    return angle;
  });
};

The calculation here is a bit more complex but getBoundingClientRect() tells us exactly where the shape is on the screen. We then calculate the direction to the mouse and convert that direction to an angle.

Symmetry

The shapes in this pattern look identical whether the rotation is or 180° and we have to account for this. I kept running into an issue where with the default behaviour the shapes would over-rotate even if they didn't need to.


If a shape is at 170° and needs to point at 10°, it could rotate 200° forward or just 20° backward.


The findClosestEquivalent() function prevents the over-rotation. Instead of always rotating forward, it checks a few possible angles (the target angle plus or minus 180° or 360°) and picks the one that’s closest to where the shape is now.

function findClosestEquivalent(targetAngle, currentRotation) {
  const candidates = [
    targetAngle,
    targetAngle + 180,
    targetAngle - 180,
    targetAngle + 360,
    targetAngle - 360,
  ];
 
  let bestTarget = targetAngle;
  let smallestDelta = Infinity;
 
  for (const candidate of candidates) {
    const delta = Math.abs(candidate - currentRotation);
    if (delta < smallestDelta) {
      smallestDelta = delta;
      bestTarget = candidate;
    }
  }
 
  return bestTarget;
}

Without this, the shapes would over-rotate when they could just rotate a little bit. Now we can use this function in the rotation calculation.

const rotate = useTransform([mouseX, mouseY], () => {
  const targetAngle =
    distance > 100 ? 0 : Math.atan2(deltaY, deltaX) * (180 / Math.PI) + 90;
 
  const closestTarget = findClosestEquivalent(targetAngle, rotationRef.current);
 
  rotationRef.current = closestTarget;
  return closestTarget;
});

We have to track it in a ref because we need to remember it between calculations without causing re-renders. This basically compares where the shape is to where it needs to go and chooses the shortest path.

Adding Animations

Now that we have the rotation values we need to add the animations. As mentioned earlier, we can use the useSpring hook to add spring animations on any value change.

const rotateSpring = useSpring(rotate, {
  mass: 0.1,
  stiffness: 200,
  damping: 30,
});
 
return (
  <motion.div
    ref={ref}
    className="will-change-transform"
    style={{ rotate: rotateSpring }}
  />
);

Without adding this, the shapes would snap to new angles instantly and there would be no animation in the pattern.

Spotlight Effect

Using the same distance calculation from earlier, we can also add a spotlight effect using opacity.

const opacity = useTransform([mouseX, mouseY], () => {
  if (distance > 150) return 0.2;
 
  const strength = 1 - distance / 150;
 
  return 0.2 + strength * 0.8;
});
 
const opacitySpring = useSpring(opacity, SPRING_CONFIG);

At the cursor's position distance=0 the shapes are fully visible opacity=1. As the distance increases, the opacity decreases until it reaches 0.2 at 150px. Beyond that the opacity stays at 0.2 as that is the default state.


The range itself is slightly larger than the reactive area which is 100px to create a halo effect around the cursor.

Resetting the Position

When the mouse leaves the container, the shapes need to return to their original position, but only if they are not already there.

if (!isFinite(mouseX.get()) || !isFinite(mouseY.get())) {
  const normalized = ((rotation % 180) + 180) % 180;
  const isAtRest = Math.abs(normalized) < 1 || Math.abs(normalized - 180) < 1;
 
  if (isAtRest) {
    return rotation;
  }
 
  return findClosestEquivalent(0, rotation);
}

We have to check if the shape isAtRest and if it is, we don't do anything. If it isn't, we rotate it back to the default position by taking the shortest available path.


This would actually be the default behaviour out of the box but since we are altering the rotation by taking the shortest path, we need to account for this when the mouse exits the pattern area.

Code

I made this component open-source, feel free to check out the full code below.

Code Image
View Code
More

If you have any questions reach me at jakub@kbo.sk or see more of my work on X.