Layered scroll scene
Background layers move slower than the foreground as you scroll, which creates depth.
CSSJavaScriptIntermediate
<div class="scene">
<div class="layer sun" data-speed="0.55"></div>
<div class="layer hills1" data-speed="0.3"></div>
<div class="layer hills2" data-speed="0.12"></div>
<div class="title" data-speed="-0.15">Parallax</div>
</div>
<div class="after"><p>Keep scrolling: layers move at different speeds.</p></div>
body{display:block;height:auto}
.scene{position:relative;height:420px;overflow:hidden;background:linear-gradient(#2a0f0b,#e5603a 85%)}
.layer{position:absolute;left:-5%;width:110%;will-change:transform}
.sun{top:90px;left:50%;width:120px;height:120px;margin-left:-60px;border-radius:50%;background:#ffd166;box-shadow:0 0 60px #ffd166}
.hills1{bottom:-20px;height:170px;background:#4e1408;clip-path:polygon(0 60%,20% 30%,40% 55%,60% 20%,80% 50%,100% 25%,100% 100%,0 100%)}
.hills2{bottom:-20px;height:130px;background:#2a0f0b;clip-path:polygon(0 45%,25% 15%,50% 50%,75% 20%,100% 45%,100% 100%,0 100%)}
.title{position:absolute;inset:0;display:grid;place-items:center;font-size:clamp(2rem,9vw,3.4rem);font-weight:800;letter-spacing:-.03em;z-index:3;text-shadow:0 4px 30px rgba(0,0,0,.4)}
.after{height:520px;display:grid;place-items:start center;padding-top:40px;color:#cfc3c1}
const layers = [...document.querySelectorAll('[data-speed]')];
let ticking = false;
function update() {
const y = scrollY;
layers.forEach(el => {
el.style.transform = `translate3d(0, ${y * el.dataset.speed}px, 0)`;
});
ticking = false;
}
addEventListener('scroll', () => {
if (!ticking) { requestAnimationFrame(update); ticking = true; }
}, { passive: true });
update();
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Parallax layers</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{display:block;place-items:normal;height:auto}
.scene{position:relative;height:420px;overflow:hidden;background:linear-gradient(#2a0f0b,#e5603a 85%)}
.layer{position:absolute;left:-5%;width:110%;will-change:transform}
.sun{top:90px;left:50%;width:120px;height:120px;margin-left:-60px;border-radius:50%;background:#ffd166;box-shadow:0 0 60px #ffd166}
.hills1{bottom:-20px;height:170px;background:#4e1408;clip-path:polygon(0 60%,20% 30%,40% 55%,60% 20%,80% 50%,100% 25%,100% 100%,0 100%)}
.hills2{bottom:-20px;height:130px;background:#2a0f0b;clip-path:polygon(0 45%,25% 15%,50% 50%,75% 20%,100% 45%,100% 100%,0 100%)}
.title{position:absolute;inset:0;display:grid;place-items:center;font-size:clamp(2rem,9vw,3.4rem);font-weight:800;letter-spacing:-.03em;z-index:3;text-shadow:0 4px 30px rgba(0,0,0,.4)}
.after{height:520px;display:grid;place-items:start center;padding-top:40px;color:#cfc3c1}
</style></head>
<body>
<div class="scene">
<div class="layer sun" data-speed="0.55"></div>
<div class="layer hills1" data-speed="0.3"></div>
<div class="layer hills2" data-speed="0.12"></div>
<div class="title" data-speed="-0.15">Parallax</div>
</div>
<div class="after"><p>Keep scrolling: layers move at different speeds.</p></div>
<script>
const layers = [...document.querySelectorAll('[data-speed]')];
let ticking = false;
function update() {
const y = scrollY;
layers.forEach(el => {
el.style.transform = `translate3d(0, ${y * el.dataset.speed}px, 0)`;
});
ticking = false;
}
addEventListener('scroll', () => {
if (!ticking) { requestAnimationFrame(update); ticking = true; }
}, { passive: true });
update();
</script>
</body></html>
Good for
Hero scenes, illustrated headers and storytelling sections.
Watch out for
Move layers with transform only, update inside requestAnimationFrame, and keep the speed differences gentle.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.