A website that's always moving with you
@dexterwritescodeCSS 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.
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.
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; }
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.