Codrops x MWG Gravity Mouse Trail

This mouse trail has a unique twist. As the user moves the cursor, images are created, fall to the bottom of the screen, bounce, and eventually fade away. Let’s see how it works!

HTML Structure

The structure here is quite simple. We will simply call the set of images we want for our effect:

<section class="mwg_free_effect002">
    <div class="medias">
        <img src="./assets/medias/01.png" alt="">
        <img src="./assets/medias/02.png" alt="">
        <img src="./assets/medias/03.png" alt="">
        <img src="./assets/medias/04.png" alt="">
    </div>
</section>

Some CSS

Now, I will hide these images using a bit of CSS.

.mwg_free_effect002 .medias img {
    width: 1px;
    height: 1px;
    top: 0;
    left: 0;
    position: absolute;
    visibility: hidden;
    pointer-events: none;
}

A quick explanation: we could have loaded these images directly via JavaScript to keep the DOM clean. So why didn’t we do that? Because this technique allows us to load the images immediately, even if they are hidden. This way, there will be no delay when an image is created with our script.

Here’s how I retrieve all the URLs of the images used in this effect. These URLs are now stored in my images array.

const root = document.querySelector('.mwg_free_effect002')
const images = [] 
root.querySelectorAll('.medias img').forEach(image => {
    images.push(image.getAttribute('src'))
})

Starting the animation

We’ll trigger a function whenever the cursor moves a certain distance. Here we track both X and Y movements to compute the total distance traveled:

const root = document.querySelector('.mwg_free_effect002')
let incr = 0, 
    oldIncrX = 0,
    oldIncrY = 0 

root.addEventListener("mousemove", e => {
    const valX = e.clientX 
    const valY = e.clientY 

    // Add the positive difference between the last two positions (X + Y) 
    incr += Math.abs(valX - oldIncrX) + Math.abs(valY - oldIncrY) 

    oldIncrX = valX 
    oldIncrY = valY
})

Here, we calculate the delta between the current cursor position and the previous position on both axes. We then add this total difference to our incr variable. We also store the deltas because we’ll need them later to determine the direction the image should drift while falling.

Triggering the Image Creation Function

Next, we’ll call the function to create an image once incr exceeds a specific threshold. To make the effect responsive, we’ll set the threshold to window.innerWidth / 8.

const root = document.querySelector('.mwg_free_effect002') 
let incr = 0, 
    oldIncrX = 0, 
    oldIncrY = 0, 
    resetDist = window.innerWidth / 8 

root.addEventListener("mousemove", e => { 
    const valX = e.clientX 
    const valY = e.clientY 

    incr += Math.abs(valX - oldIncrX) + Math.abs(valY - oldIncrY) 

    if(incr > resetDist) { 
        incr = 0 
        createMedia(valX, valY - root.getBoundingClientRect().top, valX - oldIncrX, valY - oldIncrY) 
    } 
    
    oldIncrX = valX 
    oldIncrY = valY 
})

Notice that we pass two extra parameters to createMedia(): deltaX and deltaY. These represent the direction the cursor was moving at the moment the image is created. We’ll use them to make the image drift in the same direction as it falls.

For the y position, we adjust by subtracting the distance from the top of the root element to the top of the viewport. This ensures the effect works correctly even if the page is scrolled down:

e.clientY - root.getBoundingClientRect().top

Inside the createMedia() Function

The createMedia() function now accepts four parameters: x, y, deltaX, and deltaY. We start by creating an image.

const image = document.createElement("img")
image.setAttribute('src', images[indexImg])
root.appendChild(image)

We also add a small guard: if the cursor is too close to the bottom of the viewport, we skip the creation to avoid a visually broken fall:

const H = window.innerHeight
if (y > H - 200) return

Let’s create a simple GSAP Timeline(). When the timeline finishes, the image is removed from the DOM:

const tl = gsap.timeline({ 
    onComplete: () => { 
        root.removeChild(image); 
        tl && tl.kill() 
    } 
})

The first tween handles the appearance with the same elastic bounce, but without setting x and y — those will be handled by separate tweens running simultaneously:

tl.fromTo(image, { 
    xPercent: -50 + (Math.random() - 0.5) * 80, 
    yPercent: -50 + (Math.random() - 0.5) * 10, 
    scaleX: 1.3, 
    scaleY: 1.3, 
    rotation:(Math.random() - 0.5) * 20 
}, { 
    scaleX:1, 
    scaleY:1, 
    ease: 'elastic.out(2, 0.6)', 
    duration: 0.4
})

Next, we animate the horizontal position. The image drifts in the direction the cursor was moving, using deltaX. This tween starts at the same time as the previous one thanks to the '<' position parameter:

tl.fromTo(image, { 
    x, 
}, { 
    x: '+=' + deltaX * 2, 
    rotation: 0, 
    ease: 'power1.in', 
    duration: 0.4 
}, '<')

Simultaneously, we animate the vertical position. The image falls from its initial y position to the bottom of the viewport. The yPercent: -95 combined with scale: 0.9 ensures the bottom edge of the image lands precisely at the bottom of the viewport:

tl.fromTo(image, { 
    y
}, { 
    y: '+=' + (H - y), 
    scale: 0.9, 
    yPercent: -95, 
    ease: 'back.in(1.1)', 
    duration: 0.4 
}, '<')

Finally, the bounce. After hitting the bottom, the image bounces back up and disappears. We continue drifting horizontally and add a random rotation for a natural feel:

tl.to(image, { 
    x: '+=' + deltaX * 1.6, 
    rotation:(Math.random() - 0.5) * 40, 
    ease: 'power1.in', 
    duration: 0.3 
}) 
tl.to(image, { 
    yPercent: 150, 
    ease: 'back.in(' + (1.5 + (1 - y/H)) + ')', 
    duration: 0.3 
}, '<')

Notice the dynamic easing: 'back.in(' + (1.5 + (1 - y/H)) + ')'. The higher the image starts, the stronger the overshoot on the bounce, creating a more dramatic effect for images that fall from further up.

Going further

By tweaking the random values or exploring other easing options from GSAP’s documentation, you can achieve different visual effects. Try adjusting the back.in intensity for more or less dramatic bounces. The GSAP Easing documentation page is a great playground for this!

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_free_effect002">
    <p class="content-effect">
        <span>Move your mouse to make</span>
        <span>images fall and bounce</span>
    </p>
    <div class="medias">
        <img src="assets/medias/01.png" alt="">
        <img src="assets/medias/02.png" alt="">
        <img src="assets/medias/03.png" alt="">
        <img src="assets/medias/04.png" alt="">
        <img src="assets/medias/05.png" alt="">
        <img src="assets/medias/06.png" alt="">
        <img src="assets/medias/07.png" alt="">
        <img src="assets/medias/08.png" alt="">
        <img src="assets/medias/09.png" alt="">
        <img src="assets/medias/10.png" alt="">
    </div>
</section>
.mwg_free_effect002 {
    height: 100dvh;
    overflow: hidden;
    position: relative;
}

.mwg_free_effect002 .content-effect {
    font-size: min(60px, 5.6vw);
    text-align: center;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    display: flex;
    flex-direction: column;
    position: absolute;
    align-items: center;
    letter-spacing: -0.03em;
}

.mwg_free_effect002 .content-effect span {
    display: block;
    width: max-content;
}

.mwg_free_effect002 .content-effect span:last-child {
    color: #999;
}

.mwg_free_effect002 img {
    width: 15vw;
    height: 15vw;
    position: absolute;
    object-fit: cover;
    border-radius: 4%;
    z-index: 5;
}

.mwg_free_effect002 .medias img {
    width: 1px;
    height: 1px;
    top: 0;
    left: 0;
    position: absolute;
    visibility: hidden;
    pointer-events: none;
}

@media (max-width: 768px) {
    .mwg_free_effect002 img {
        width: 35vw;
        height: 35vw;
    }
}
const root = document.querySelector('.mwg_free_effect002')
const images = []
root.querySelectorAll('.medias img').forEach(image => {
    images.push(image.getAttribute('src'))
})

let incr = 0,
    oldIncrX = 0,
    oldIncrY = 0,
    firstMove = true,
    indexImg = 0

const isCoarsePointer = window.matchMedia('(hover: none)').matches
const resetDist = window.innerWidth / (isCoarsePointer ? 6 : 8)

const W = window.innerWidth
const H = window.innerHeight
const clampX = gsap.utils.clamp(0, W)
const clampY = gsap.utils.clamp(0, H)

function applyMove(clientX, clientY) {
    const valX = clampX(clientX)
    const valY = clampY(clientY)

    if (firstMove) {
        firstMove = false
        oldIncrX = valX
        oldIncrY = valY
        return
    }

    incr += Math.abs(valX - oldIncrX) + Math.abs(valY - oldIncrY)

    if (incr > resetDist) {
        incr = 0
        createMedia(valX, valY - root.getBoundingClientRect().top, valX - oldIncrX, valY - oldIncrY)
    }

    oldIncrX = valX
    oldIncrY = valY
}

function handleMouseMove(e) {
    applyMove(e.clientX, e.clientY)
}

function handleTouchMove(e) {
    if (!e.touches || !e.touches[0]) return
    applyMove(e.touches[0].clientX, e.touches[0].clientY)
}

root.addEventListener('mousemove', handleMouseMove)
root.addEventListener('touchstart', handleTouchMove, { passive: true })
root.addEventListener('touchmove', handleTouchMove, { passive: true })

function createMedia(x, y, deltaX, deltaY) {
    const H = window.innerHeight

    if (y > H - 200) return

    const image = document.createElement("img")

    image.setAttribute('src', images[indexImg])
    root.appendChild(image)

    const tl = gsap.timeline({
        onComplete: () => {
            root.removeChild(image);
            tl && tl.kill()
        }
    })

    tl.fromTo(image, {
        xPercent: -50 + (Math.random() - 0.5) * 80,
        yPercent: -50 + (Math.random() - 0.5) * 10,
        scaleX: 1.3,
        scaleY: 1.3,
        rotation:(Math.random() - 0.5) * 20
    }, {
        scaleX:1,
        scaleY:1,
        ease: 'elastic.out(2, 0.6)',
        duration: 0.4
    })

    tl.fromTo(image, {
        x,
    }, {
        x: '+=' + deltaX * 2,
        rotation: 0,
        ease: 'power1.in',
        duration: 0.4
    }, '<')

    tl.fromTo(image, {
        y
    }, {
        y: '+=' + (H - y),
        scale: 0.9,
        yPercent: -95,
        ease: 'back.in(1.1)',
        duration: 0.4
    }, '<')

    tl.to(image, {
        x: '+=' + deltaX * 1.6,
        rotation:(Math.random() - 0.5) * 40,
        ease: 'power1.in',
        duration: 0.3
    })
    tl.to(image, {
        yPercent: 150,
        ease: 'back.in(' + (1.5 + (1 - y/H)) + ')',
        duration: 0.3
    }, '<')

    indexImg = (indexImg + 1) % images.length
}

Photo

  • Andréa Devillier
  • Dan
  • Gabii Fernandez
  • Jacquelin Perez
  • Katrin Bolovtsova
  • Lucas Gouvêa
  • Rafael Minguet Delgado
Quick Setup

Make sure GSAP is enabled in the Page Settings panel:

GSAP
On
Files Files

Wanna learn
something else?

Back to collection Back to collection