Mouse parallax
Layers shift against the cursor by different amounts, with smooth easing, so the scene feels like it has real depth.
CSSJavaScriptIntermediate
<div class="scene" id="scene">
<div class="l ring r1" data-depth="0.3"></div>
<div class="l ring r2" data-depth="0.8"></div>
<div class="l dot d1" data-depth="1.2"></div>
<div class="l dot d2" data-depth="0.5"></div>
<div class="l title" data-depth="0.15"><h1>Move your mouse</h1></div>
</div>
body{overflow:hidden;background:radial-gradient(circle at 50% 60%,#3a1510,#140807 70%)}
.scene{position:fixed;inset:0}
.l{position:absolute;will-change:transform}
.ring{border:2px solid #f08a5d;border-radius:50%}
.r1{left:12%;top:18%;width:90px;height:90px;opacity:.5}
.r2{right:14%;top:58%;width:150px;height:150px;border-color:#f3d9c8;opacity:.6}
.dot{border-radius:50%;background:#d98a8a}
.d1{left:30%;top:68%;width:26px;height:26px}.d2{right:28%;top:22%;width:18px;height:18px;background:#ffd166}
.title{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.title h1{margin:0;font-size:clamp(2.4rem,10vw,5rem);letter-spacing:-.04em}
const layers = [...document.querySelectorAll('[data-depth]')];
const max = 40; // px of shift at depth 1
let tx = 0, ty = 0, cx = 0, cy = 0; // target and current (-1 to 1)
addEventListener('pointermove', e => {
tx = (e.clientX / innerWidth - .5) * 2;
ty = (e.clientY / innerHeight - .5) * 2;
});
(function loop() {
cx += (tx - cx) * 0.07; // ease toward the target
cy += (ty - cy) * 0.07;
layers.forEach(l => {
const d = l.dataset.depth * max;
l.style.transform = `translate3d(${-cx * d}px, ${-cy * d}px, 0)`;
});
requestAnimationFrame(loop);
})();
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Mouse parallax</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{display:grid;place-items:center;background:#140807;color:#fffffc;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
body{overflow:hidden;background:radial-gradient(circle at 50% 60%,#3a1510,#140807 70%)}
.scene{position:fixed;inset:0}
.l{position:absolute;will-change:transform}
.ring{border:2px solid #f08a5d;border-radius:50%}
.r1{left:12%;top:18%;width:90px;height:90px;opacity:.5}
.r2{right:14%;top:58%;width:150px;height:150px;border-color:#f3d9c8;opacity:.6}
.dot{border-radius:50%;background:#d98a8a}
.d1{left:30%;top:68%;width:26px;height:26px}.d2{right:28%;top:22%;width:18px;height:18px;background:#ffd166}
.title{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.title h1{margin:0;font-size:clamp(2.4rem,10vw,5rem);letter-spacing:-.04em}
</style></head>
<body>
<div class="scene" id="scene">
<div class="l ring r1" data-depth="0.3"></div>
<div class="l ring r2" data-depth="0.8"></div>
<div class="l dot d1" data-depth="1.2"></div>
<div class="l dot d2" data-depth="0.5"></div>
<div class="l title" data-depth="0.15"><h1>Move your mouse</h1></div>
</div>
<script>
const layers = [...document.querySelectorAll('[data-depth]')];
const max = 40; // px of shift at depth 1
let tx = 0, ty = 0, cx = 0, cy = 0; // target and current (-1 to 1)
addEventListener('pointermove', e => {
tx = (e.clientX / innerWidth - .5) * 2;
ty = (e.clientY / innerHeight - .5) * 2;
});
(function loop() {
cx += (tx - cx) * 0.07; // ease toward the target
cy += (ty - cy) * 0.07;
layers.forEach(l => {
const d = l.dataset.depth * max;
l.style.transform = `translate3d(${-cx * d}px, ${-cy * d}px, 0)`;
});
requestAnimationFrame(loop);
})();
</script>
</body></html>
Good for
Hero scenes and landing screens on desktop. Pair it with tilt on phones.
Watch out for
Ease toward the target in requestAnimationFrame instead of snapping. Keep the maximum shift small (10 to 40px) and skip it for reduced motion.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.