DEV Community

loading...
Cover image for How To: Cursor Tracking Parallax

How To: Cursor Tracking Parallax

Jhey Tompkins
I make awesome things for awesome people!
Originally published at jhey.dev on ・5 min read

Ever seen those effects where elements on the screen respond to mouse movement? They're used quite often for parallax-like effects or eyes following a cursor. I use the effect on jhey.dev for the shades glare on the main bear head.

I don't know the technical name. Let's go with "Cursor Tracking Parallax".

The good news is this technique doesn't take much time to make and adds a little something extra to your designs. Remember, it's the little details.


Let's make a face! We'll start with some markup.

<div class="face">
  <div class="face__eyes">
    <div class="eye eye--left"></div>
    <div class="eye eye--right"></div>
  </div>
  <div class="face__mouth"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

And we've gone ahead and styled it up 💅

Remember, you can view the compiled HTML, CSS, and JavaScript in CodePen. Use the dropdown for a source panel and press "View Compiled HTML/CSS/JavaScript".

That face is great. But, it'd be cooler if we could give it a little life.

To do this, we can use CSS variables with an event listener for "pointermove".

document.addEventListener('pointermove', () => {
  // Make those features move by updating some CSS variables.
})
Enter fullscreen mode Exit fullscreen mode

We want to limit the movement of those features though. We don't want them flying all over the place. We want "subtle".

Let's start by updating the CSS for our eyes container. That's important. We don't need to transition each eye. We're going to use scoped CSS variables in a transform.

.face__eyes {
  transform: translate(calc(var(--x, 0) * 1px), calc(var(--y, 0) * 1px));
}
Enter fullscreen mode Exit fullscreen mode

Note how we're using calc with the value of 1px. It's not a bad habit to leave some CSS variables unitless. This gives us room to change to a different unit with ease.

There's no change yet though. The --x and --y values will fallback to 0. But, you can play with this demo to see how updating the value would affect the eyes.

Now. How about those scripts? We need a function that maps our cursor position to some defined range and outputs a value for us.

A diagram showing "50" passing through the ranges and coming out as "750"

To do this we can create a mapping function.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper) => {
  const INPUT_RANGE = inputUpper - inputLower
  const OUTPUT_RANGE = outputUpper - outputLower
  return value => outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
}
Enter fullscreen mode Exit fullscreen mode

Our mapping function takes an input range and an output range. Then it returns a function we can use to map one value to another.

Let's run through what's happening there. We pass the function two ranges for input and output. After calculating the range, we return a function. The function does the magic.

  1. Calculates the fractional value of an input value against the input range.
  2. Multiplies that by the output range.
  3. Add that to the lower bounds of the output range.

Consider this example with an input range of 0 to 100, an output range of 500 to 1000, and an input of 50.

50 => 500 + (((50 - 0) / 100) * 500))
50 => 500 + (0.5 * 500)
50 => 500 + 250
50 => 750
Enter fullscreen mode Exit fullscreen mode

We need to tie this up to our CSS variable transform and we're there! Here's how we can hook up the x translation for our eyes.

const BOUNDS = 20
const update = ({ x, y }) => {
  const POS_X = mapRange(0, window.innerWidth, -BOUNDS, BOUNDS)(x)
  EYES.style.setProperty('--x', POS_X)
}
document.addEventListener('pointermove', update)
Enter fullscreen mode Exit fullscreen mode

And that works!

All that's left to do is hook up the other axis and features. Notice how we are declaring a "BOUNDS" that we use. For the y-axis, we follow the same procedure with window.innerHeight as an input.

But, what about the mouth? Well, this is where the power of scoped CSS variables comes in.

Instead of setting the style on the eyes container, let's set it on the face element itself.

const FACE = document.querySelector('.face')
const update = ({ x, y }) => {
  const POS_X = mapRange(0, window.innerWidth, -BOUNDS, BOUNDS)(x)
  const POS_Y = mapRange(0, window.innerHeight, -BOUNDS, BOUNDS)(y)
  FACE.style.setProperty('--x', POS_X)
  FACE.style.setProperty('--y', POS_Y)
}
Enter fullscreen mode Exit fullscreen mode

Making those changes won't break anything. That's CSS variable scope at work. The variable values will cascade down to the eyes container still. But now the mouth also has access and we can use the same transform on it. The starting translateX is used to center the mouth before another translation.

.face__mouth {
  transform: translateX(-50%) translate(calc(var(--x, 0) * 1px), calc(var(--y, 0) * 1px));
}
Enter fullscreen mode Exit fullscreen mode

And now the mouth moves too!

But, it doesn't look right. It moves in sync with the eyes which feels a little off. This is one of those "attention to detail" things that's worth picking up. For example, if our faces had ears and the eyes went up, where would the ears go? Down! Check it in a mirror, I won't judge you. I've done stranger things for "details" 😅

How do we solve this then? Well, remember how I mentioned using calc with a unitless value back near the start? That comes in handy now.

We've implemented our JavaScript and CSS in a way that they have a separation of concerns. That's good! Our JavaScript is working out the cursor mapping range and passing it to our CSS. It doesn't care what we do with it there. In fact, the "BOUNDS" could be a nice round number like 100 and we could do what we please with it on the CSS side.

The individual features of the face handle their own transforms. Currently, they both use a coefficient of 1px.

.face__eyes {
  transform: translate(calc(var(--x, 0) * 1px), calc(var(--y, 0) * 1px));
}
.face__mouth {
  transform: translateX(-50%) translate(calc(var(--x, 0) * 1px), calc(var(--y, 0) * 1px));
}
Enter fullscreen mode Exit fullscreen mode

But, what if we changed the mouth to use a coefficient of -1px?

.face__mouth {
  transform: translateX(-50%) translate(calc(var(--x, 0) * -1px), calc(var(--y, 0) * -1px));
}
Enter fullscreen mode Exit fullscreen mode

Now the mouth moves in the opposite direction to the eyes.

But, we only had to change the coefficient in our CSS. That's one of the superpowers of using scoped CSS variables but keeping them unitless. We can power a scene with one variable whilst maintaining a good separation of concerns.

Make a couple of tweaks and we have a cursor tracking face using CSS variables!

But, you don't need to only use it on faces. You can use it for lots of things. Another "nice touch" is creating parallax-like icon backgrounds with it. The trick there is to update the background-position with CSS variables.

I'm using that effect in this demo. It's not the "main event". But, it's nice little extra.

⚠️ This demo contains audio ⚠️

Here's a standalone version of an icon background to play with. The trick is to create a tile with an icon you like and then lean on background-repeat.

In this demo, you can configure the coefficient. This plays on the fact that we are separating concerns and letting CSS do what it wants with the value.

That's it!

This is one way you can do "Cursor Tracking Parallax" with JavaScript and scoped CSS variables. I'm excited to see what you do with these techniques. What else could you make move? As always, let me know what you think and see you for the next one!

All the code is available in this CodePen Collection.

Stay Awesome! ʕ •ᴥ•ʔ

Discussion (7)

Collapse
ob profile image
Önder Bakırtaş

That high five was loud! I almost jumped out of my chair. Great post, but please add some kind of notice there.

Collapse
jh3y profile image
Jhey Tompkins Author

Done 👍

Collapse
kinslowdian profile image
Simon Kinslow

Great tutorial and really fun to build. Can someone explain what is happening in the update function on lines 3 & 4 with the two sets of parentheses, where x & y are added on the end of POS_X and POS_Y?

Collapse
pfacklam profile image
Paul Facklam

Great article! Very inspiring. I will try it.

Collapse
jh3y profile image
Jhey Tompkins Author

Awesome. Look forward to seeing what you make! \ʕ •ᴥ•ʔ/

Collapse
_moodybones profile image
Mel Jones ✌️🌞🤚

So fun! Thanks Jhey!

Collapse
jh3y profile image
Jhey Tompkins Author

You're more than welcome! ʕ •ᴥ•ʔ