Big news: GSAP plugins are now free! To celebrate, here’s a demo that makes use of the InertiaPlugin. In this effect, images move when hovered. The hovered image shifts in the direction of the mouse movement, with an intensity based on the speed of that movement. Let’s begin!
HTML Structure
The HTML structure for this effect is fairly straightforward. All cards are placed inside a container that’s centered both horizontally and vertically.
<div class="medias">
<div class="media">
<img src="./assets/medias/01.png" alt="">
</div>
<div class="media">
<img src="./assets/medias/02.png" alt="">
</div>
<div class="media">
<img src="./assets/medias/03.png" alt="">
</div>
...
</div>
Some CSS
We’ll arrange the cards side by side in rows of 4 using the display: grid
property.
.mwg_effect000 .medias {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1vw;
}
Each image keeps a square ratio. To optimize its transformations, I use the will-change: transform
property. This tells the browser that this property will change frequently throughout the effect.
.mwg_effect000 .medias img {
width: 11vw;
height: 11vw;
object-fit: contain;
border-radius: 4%;
pointer-events: none;
will-change: transform;
}
Movement delta
Now let’s calculate the movement delta of the mouse. For this part, I start by creating a mousemove
event that triggers as soon as the user moves its mouse.
Calculating the delta is quite simple: we just store the previous x
and y
values in a variable, then subtract them from the current values.
let oldX = 0,
oldY = 0,
deltaX = 0,
deltaY = 0
const root = document.querySelector('.mwg_effect000')
root.addEventListener("mousemove", (e) => {
// Calculate horizontal movement since the last mouse position
deltaX = e.clientX - oldX;
// Calculate vertical movement since the last mouse position
deltaY = e.clientY - oldY;
// Update old coordinates with the current mouse position
oldIncrX = e.clientX;
oldIncrY = e.clientY;
})
Using Inertia
Now for the most exciting part of the effect: we’re going to trigger inertia on the x
and y
axes when the user hovers over an image. To do that, we first declare a mouseenter
event for each image in the effect.
root.querySelectorAll('.medias div').forEach(el => {
// Add an event listener for when the mouse enters each media
el.addEventListener('mouseenter', () => {
// The magic happens here
})
})
Each time the event is triggered, we create a gsap.timeline()
. This timeline will play a sequence of tweens. For performance reasons, each timeline is killed as soon as it’s done playing.
root.querySelectorAll('.medias div').forEach(el => {
el.addEventListener('mouseenter', () => {
const tl = gsap.timeline({
onComplete: () => {
tl.kill()
}
})
tl.timeScale(1.2) // Animation will play 20% faster than normal
})
})
The first tween in the timeline uses GSAP’s InertiaPlugin. By retrieving the deltaX
and deltaY
values from our mousemove
event, we apply a transformation to the hovered image using the velocity
property. You’ll notice we’re using the end
property as well. By setting it to 0, we define that the motion should end right where it started—so the image smoothly returns to its initial position once the animation completes.
root.querySelectorAll('.medias div').forEach(el => {
el.addEventListener('mouseenter', () => {
...
const media = el.querySelector('img')
tl.to(media, {
inertia: {
x: {
velocity: deltaX * 40, // Higher number = movement amplified
end: 0 // Go back to the initial position
},
y: {
velocity: deltaY * 40, // Higher number = movement amplified
end: 0 // Go back to the initial position
},
},
})
})
})
Let’s add a second tween to bring a bit more life to our animation. This one will randomly rotate the image by an angle within a defined range.
Let’s take a closer look at the native Math.random()
method, which returns a random value between 0 and 1. Following this logic, Math.random() - 0.5
returns a random value between -0.5 and 0.5. When multiplied by another value, it increases the range of variation. For example, to randomly rotate a media by an angle between -15 and 15 degrees, I use:
(Math.random() - 0.5) * 30
We’ll combine this calculation with the gsap.fromTo()
method, which lets you define both the starting and ending values of an animation.
root.querySelectorAll('.medias div').forEach(el => {
el.addEventListener('mouseenter', () => {
...
tl.fromTo(media, {
rotate: 0
}, {
duration: 0.4,
rotate: (Math.random() - 0.5) * 30, // Returns a value between -15 & 15
yoyo: true,
repeat: 1,
ease: 'power1.inOut' // Will slow at the begin and the end
}, '<') // Means that the animation starts at the same time as the previous tween
})
})
You’ll notice we’re using the yoyo
and repeat
properties. The first makes the animation play in reverse once it completes, and the second sets how many times it will repeat. This way, the image rotates and then returns to its original angle.
Go further
To take this a step further, we could increase the z-index
of each image when hovered, so it always appears on top of the others.
Effects like this can sometimes behave unpredictably on touch devices, especially on mobile. In such cases, I usually disable the effect and present a simpler layout to the user. If you’d like to see how to implement this, check out our Go Further page
Final code
<section class="mwg_effect000">
<div class="header">
<div>
<p class="button button1">
<img src="assets/medias/01.png" alt="">
<span>3d & stuff</span>
</p>
</div>
<div>12 items saved in your collection</div>
<div>
<p class="button button2">Add more</p>
</div>
</div>
<div class="medias">
<div class="media"><img src="assets/medias/01.png" alt=""></div>
<div class="media"><img src="assets/medias/02.png" alt=""></div>
<div class="media"><img src="assets/medias/03.png" alt=""></div>
<div class="media"><img src="assets/medias/04.png" alt=""></div>
<div class="media"><img src="assets/medias/05.png" alt=""></div>
<div class="media"><img src="assets/medias/06.png" alt=""></div>
<div class="media"><img src="assets/medias/07.png" alt=""></div>
<div class="media"><img src="assets/medias/08.png" alt=""></div>
<div class="media"><img src="assets/medias/09.png" alt=""></div>
<div class="media"><img src="assets/medias/10.png" alt=""></div>
<div class="media"><img src="assets/medias/11.png" alt=""></div>
<div class="media"><img src="assets/medias/12.png" alt=""></div>
</div>
</section>
.mwg_effect000 {
height: 100vh;
overflow: hidden;
position: relative;
display: grid;
place-items: center;
}
.mwg_effect000 .header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
border-bottom: 1px solid #323232;
padding: 20px 25px;
color: #BAB8B9;
}
.mwg_effect000 .header div:nth-child(2) {
font-size: 26px;
}
.mwg_effect000 .header div:last-child {
display: flex;
justify-content: flex-end;
}
.mwg_effect000 .button {
font-size: 14px;
text-transform: uppercase;
border-radius: 24px;
height: 48px;
gap: 5px;
padding: 0 20px;
display: flex;
align-items: center;
width: max-content;
}
.mwg_effect000 .button1 {
background-color: #232323;
}
.mwg_effect000 .button2 {
border: 1px solid #323232;
}
.mwg_effect000 .button img {
width: 22px;
height: auto;
display: block;
}
.mwg_effect000 .medias {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1vw;
}
.mwg_effect000 .medias img {
width: 11vw;
height: 11vw;
object-fit: contain;
border-radius: 4%;
display: block;
pointer-events: none;
will-change: transform;
}
@media (max-width: 768px) {
.mwg_effect000 .header {
padding: 15px;
display: flex;
justify-content: space-between;
}
.mwg_effect000 .header div:nth-child(2) {
display: none;
}
.mwg_effect000 .medias {
gap: 2vw;
}
.mwg_effect000 .medias img {
width: 18vw;
height: 18vw;
}
}
window.addEventListener("DOMContentLoaded", () => {
gsap.registerPlugin(InertiaPlugin)
let oldX = 0,
oldY = 0,
deltaX = 0,
deltaY = 0
const root = document.querySelector('.mwg_effect000')
root.addEventListener("mousemove", (e) => {
// Calculate horizontal movement since the last mouse position
deltaX = e.clientX - oldX;
// Calculate vertical movement since the last mouse position
deltaY = e.clientY - oldY;
// Update old coordinates with the current mouse position
oldX = e.clientX;
oldY = e.clientY;
})
root.querySelectorAll('.media').forEach(el => {
// Add an event listener for when the mouse enters each media
el.addEventListener('mouseenter', () => {
const tl = gsap.timeline({
onComplete: () => {
tl.kill()
}
})
tl.timeScale(1.2) // Animation will play 20% faster than normal
const image = el.querySelector('img')
tl.to(image, {
inertia: {
x: {
velocity: deltaX * 30, // Higher number = movement amplified
end: 0 // Go back to the initial position
},
y: {
velocity: deltaY * 30, // Higher number = movement amplified
end: 0 // Go back to the initial position
},
},
})
tl.fromTo(image, {
rotate: 0
}, {
duration: 0.4,
rotate:(Math.random() - 0.5) * 30, // Returns a value between -15 & 15
yoyo: true,
repeat: 1,
ease: 'power1.inOut' // Will slow at the begin and the end
}, '<') // The animation starts at the same time as the previous tween
})
})
})
Key JS Methods
GSAP Plugins
Key GSAP Methods
Key GSAP Properties
3D
- Womp
Photo
- Ihza Akbar
Illustration
- Sarah Fatmi