Micro-Interactivity

A website that's always moving with you

@dexterwritescode

CSS transforms make it easy to add movement to html elements without sacrificing easy positioning or incurring expensive layout calculations.

We can use the mousemove listener, combined with the movementX and movementY properties of MouseEvent to apply small animations to specific DOM elements whenever the user moves the mouse.

If you've done this kind of thing before, hop over to the code view to see the good stuff. If not, read on and we can break it all down step by step.

In this demo, everything moves. But that probably isn't what you want in most sites. So we need some selector we can use to determine what elements should move, and by how much. I'm using the data attribute level to select elements here, the value of level determines the movement amount for that element.

The transforms we apply to each element are cumulative. This means stacking moving elements inside of other moving elements multiplies the transform of the inner elements, giving us even more control without any extra code.

Diving in

Don't worry. There isn't much code.

We really only need to do 3 things in javascript for this effect to work. First we need to grab all the elements with our data-level attribute and store them somewhere. Then we bind a function to the mousemove event to transform our elements. And finally we need to reset the transforms after the mouse has stopped moving.

Grabbing all the elements we want to move is super easy. We can use document.querySelectorAll combined with the selector "[data-level]" to select all element with the data-level attribute applied. Unfortunately we can't map over the NodeList that querySelectorAll returns directly, but we can wrap it in Array.from to give us something easier to work with. From there, we can use map to convert the array of DOM nodes, to an array of objects containing the DOM node, and the level of each node.

const elements = Array.from(
  document.querySelectorAll("[data-level]")
).map((ele) => {
  return {
    element: ele,
    level: parseInt(ele.dataset.level),
  }
})
      

Now we have all the elements parsed and packaged, we can do the fun stuff. First we need to bind a function to the mousemove event provided by document, this calls our transform code every time the mouse moves. In our mousemove callback, first we iterate over all all the elements we saved earlier. Then, for each element, update its style.transform property with our custom transforms.

The transform css property accepts all transforms at once, so we can use a string literal to keep things clean. The movementX and movementY properties of the MouseEvent hold the mouse delta, or how much the mouse position has changed since the last mousemove event. This is what we need to have movement that responds to the mouse but doesn't depend on the mouse being in a specific place for things to look nice. We care about mouse movement, not mouse position.

The actual transformations are totally up to you! I ended up applying translateX and translateY, and rotateX and rotateY. I scaled down the rotation by a small amount to keep it subtle, and swapped the axis of rotation relative to the mouse ( movementY controls rotateX and so on ). I didn't think of this at first, but imagining the axis of rotation on each element it started to make sense. Unfortunately, browsers don't all respond to transforms in the same way. During testing, I discovered that Safari does no like to rotate text element, and the overall effect in safari was pretty pretty muted compared with chrome. I'm not sure why that is. In the end, I used is.js to check if the page was opened in safari, if it is I disable the rotation and scale up the translate a bit.

  document.addEventListener('mousemove', (evt) => {
   elements.forEach((ele) => {
     let x = evt.movementX
     let y = evt.movementY
     if (is.safari()) {
       x *= 3
       y *= 3
     }

     let transform = `
      translateX(${x * ele.level}px)
      translateY(${y * ele.level}px)
    `

     if (!is.safari()) {
        transform += ` rotateY(${x * ele.level * 0.7}deg)
        rotateX(${y * ele.level * 0.7}deg)` 
     }

     ele.element.style.transform = transform
    })
  })
      

The last thing we need to do in JS world is reset all the transforms we've applied when the mouse stops moving. Without this, things get real funky if someone shakes the mouse really fast. My first thought here was to use setInterval to call a reset function every 100 milliseconds or so. This worked great in Chrome and Safari, but had some weird side effects in Firefox. The other solution is to use setTimeout to call a reset function 50ms after the mousemove event. This works great as long as we remember to clear that timeout if the mouse moves again before the function gets called. The code is pretty straightforward. We need to define a global variable to store the timeout id, then in the mousemove callback we check if the timeoutID variable has been set, if it has we call clearTimeout(timeoutID) to cancel that reset. Then at the end of the mousemove callback, we use timeoutID = setTimeout(reset, 50) to schedule the reset function for 50 milliseconds in the future. If we move the mouse again before reset is called, the if statement above will clear this timeout and schedule a new one.

After that we just need to define a reset function that sets all the transforms we've changed back to 0 and set timeoutID to false.

  let timeoutID
  document.addEventListener('mousemove', (evt) => {
    if ( timeoutID ) {
       clearTimeout(timeoutID) 
    }
    elements.forEach((ele) => {
      ... do the transform stuff ...
    })

    timeoutID = setTimeout(reset, 50)
  })

  function reset() {
    elements.forEach((ele) => {
      ele.element.style.transform = `
        translateX(0px)
        translateY(0px)
        rotateY(0)
        rotateX(0) 
      `; 
     })
    timeoutID = false
  }  
      

That's it for JS! But if you're following along, your version might look a little jerky and stuttery. We could fix this in Javascript with a smoothing function or maybe tweening between the last transform and the current one, but it turns out CSS can do this for us and it takes exactly 0 effort. The css transition property allows us to define a transition time and curve for any style rule that changes on an element. We can leverage this with our transforms by selecting all our moving elements, and adding a transition rule to smooth out any changes to the transform property of those elements.

  [data-level] {
    transition: transform .2s ease-in-out; 
  }
      

Thats it!

but maybe a few more things I worked out along the way.

If you are doing anything more the translate you might want to add a transform-origin: center; all your moving elements. Without this, transforms like rotate and scale be calculated from the top left corned of each element, rather than the center of each element which looks a bit more natural. Also, transitions cannot be applied to inline elements, so make sure to set your span elements to display: inline-block; to get things working.

Thanks for sticking with me. Feel free to remix this project and use it in your own experiments.