Face Follow Cursor (Mascot)
A mascot face that tracks the cursor using GSAP quickTo, with movement intensity and per-target multipliers set via data attributes. ScrollTrigger pauses tracking when the section is out of view. A bonus CSS hover state swaps the face expression.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>Code
<div class="mascot">
<!-- body SVG goes here -->
<div data-face="35" class="mascot__face">
<svg data-face-target="" class="mascot__face-svg" xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 112 100" fill="none">
<!-- face paths go here -->
</svg>
</div>
</div>.mascot {
justify-content: center;
align-items: center;
width: max(10em, 20vh);
display: flex;
position: relative;
}
.mascot__body-svg {
width: 100%;
}
.mascot__face {
border-radius: 50%;
justify-content: center;
align-items: center;
width: 50%;
height: 50%;
display: flex;
position: absolute;
top: 17%;
left: 22%;
}
.mascot__face-svg {
width: 40%;
position: relative;
}
/* Bonus: hover expression swap */
.mascot .mascot__face-svg path:nth-child(4),
.mascot:hover .mascot__face-svg path:nth-child(3) {
opacity: 0;
}
.mascot:hover .mascot__face-svg path:nth-child(4) {
opacity: 1;
}function initFaceFollowCursor() {
const faceSections = document.querySelectorAll('[data-face]');
if (!faceSections.length) return;
faceSections.forEach(section => {
const baseIntensity = parseFloat(section.getAttribute('data-face')) || 30;
const targets = Array.from(section.querySelectorAll('[data-face-target]')).map(el => {
const factor = parseFloat(el.getAttribute('data-face-target')) || 1;
return {
el,
factor,
intensity: baseIntensity * factor,
setX: gsap.quickTo(el, 'xPercent', { duration: 0.3, ease: 'power2' }),
setY: gsap.quickTo(el, 'yPercent', { duration: 0.3, ease: 'power2' }),
};
});
let isTracking = false;
let isRAFRunning = false;
let lastMouse = { x: 0, y: 0 };
const update = () => {
const mx = lastMouse.x;
const my = lastMouse.y;
targets.forEach(target => {
const r = target.el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const rx = r.width / 2;
const ry = r.height / 2;
let nx = (mx - cx) / rx;
let ny = (my - cy) / ry;
const mag = Math.hypot(nx, ny);
if (mag > 1) { nx /= mag; ny /= mag; }
target.setX(nx * target.intensity);
target.setY(ny * target.intensity);
});
isRAFRunning = false;
};
const onMouseMove = e => {
lastMouse.x = e.clientX;
lastMouse.y = e.clientY;
if (!isRAFRunning) {
requestAnimationFrame(update);
isRAFRunning = true;
}
};
const startTracking = () => {
if (!isTracking) {
window.addEventListener('mousemove', onMouseMove);
isTracking = true;
}
};
const stopTracking = () => {
if (isTracking) {
window.removeEventListener('mousemove', onMouseMove);
isTracking = false;
}
};
ScrollTrigger.create({
trigger: section,
start: 'top bottom',
end: 'bottom top',
onEnter: startTracking,
onEnterBack: startTracking,
onLeave: stopTracking,
onLeaveBack: stopTracking,
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initFaceFollowCursor();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-face | number | "30" | Place on any wrapper element to define a tracking section. The value sets the base movement intensity in xPercent/yPercent units. Mouse tracking is active only while the section is in the viewport (via ScrollTrigger). |
| data-face-target | number (multiplier) | "1" | Place on each element inside [data-face] that should follow the cursor. The attribute value multiplies the base intensity — e.g. "1.5" makes that element move 1.5× more than the base. |
Notes
- •Requires GSAP and ScrollTrigger loaded via CDN before the script runs.
- •Multiple independent [data-face] sections are supported on the same page.
- •The cursor position is normalized to a unit vector so the movement caps at the set intensity regardless of how far the cursor is from the element.
- •The mousemove listener is passive (batched via rAF) and removed entirely when the section is out of view.
- •The hover expression swap is optional CSS — remove those rules if you do not need it.
Guide
Movement calculation
On each mousemove (batched via requestAnimationFrame), the script calculates the cursor's normalized offset from each target's center (−1 to 1 on each axis). If the magnitude exceeds 1 the vector is clamped to a unit vector, preventing extreme offsets when the cursor is far away. The result is multiplied by the target's intensity and applied via GSAP quickTo.
quickTo for smooth tracking
Each target gets its own gsap.quickTo instance for xPercent and yPercent (duration: 0.3, ease: power2). quickTo is more efficient than creating new tweens on every frame — it updates the destination of an already-running tween.
ScrollTrigger performance optimization
The mousemove listener is added and removed based on whether the [data-face] section is in the viewport. This means the tracking is completely inactive when the mascot is scrolled out of view, avoiding unnecessary computation.
Multiple targets and multipliers
You can place multiple [data-face-target] elements inside a single [data-face] section to create a parallax-like effect where different layers move at different speeds.
<div data-face="30">
<div data-face-target="0.5" class="background-layer">...</div>
<div data-face-target="1" class="face">...</div>
<div data-face-target="1.5" class="eyes">...</div>
</div>Hover expression swap
The bonus CSS uses nth-child selectors to hide the 4th SVG path by default and show it on hover (while hiding the 3rd), creating a simple expression toggle without any JavaScript.