Horizontal parallax scene
Scrolling down pans a landscape sideways. Distant mountains crawl, near trees race past, so the world has depth.
CSSJavaScriptAdvanced
<div class="intro"><p>Scroll down.<br>The landscape will pan sideways.</p></div>
<section class="track" id="track"><div class="pin" id="pin">
<div class="sun"></div>
<div class="ground"></div>
</div></section>
<div class="outro"><p>Journey over.</p></div>
body{display:block;height:auto}
.intro,.outro{height:60vh;display:grid;place-items:center;color:#cfc3c1;text-align:center}
.track{height:320vh}
.pin{position:sticky;top:0;height:100vh;overflow:hidden;background:linear-gradient(#2a0f0b,#e5603a 78%)}
.layer{position:absolute;left:0;bottom:0;display:flex;align-items:flex-end;will-change:transform}
.layer i{flex:none;display:block;clip-path:polygon(50% 0,100% 100%,0 100%)}
.sun{position:absolute;left:62%;top:20%;width:90px;height:90px;border-radius:50%;background:#ffd166;box-shadow:0 0 60px #ffd166}
.ground{position:absolute;left:0;right:0;bottom:0;height:12%;background:#1f0f0e}
const track = document.getElementById('track');
const pin = document.getElementById('pin');
// A tiny seeded random so the scene is the same every time
let seed = 7;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
// speed = how much of the full travel this layer moves (far = small, near = large)
const layers = [
{ n: 7, w: 330, h: [260, 380], color: '#4e1408', speed: 0.25 },
{ n: 12, w: 220, h: [150, 260], color: '#3a0804', speed: 0.5 },
{ n: 22, w: 120, h: [90, 190], color: '#1f0f0e', speed: 1 },
].map(cfg => {
const el = document.createElement('div');
el.className = 'layer';
for (let i = 0; i < cfg.n; i++) {
const t = document.createElement('i');
t.style.width = cfg.w + 'px';
t.style.height = cfg.h[0] + rnd() * (cfg.h[1] - cfg.h[0]) + 'px';
t.style.background = cfg.color;
t.style.marginRight = -cfg.w * 0.25 + 'px';
el.append(t);
}
pin.append(el);
return { el, speed: cfg.speed };
});
pin.append(pin.querySelector('.ground')); // keep the ground on top of the layers
function update() {
const scrollable = track.offsetHeight - innerHeight;
const p = Math.min(Math.max(-track.getBoundingClientRect().top / scrollable, 0), 1);
layers.forEach(l => {
const travel = Math.max(l.el.scrollWidth - innerWidth, 0) * l.speed;
l.el.style.transform = `translate3d(${-p * travel}px, 0, 0)`;
});
}
addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
addEventListener('resize', update);
update();
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Horizontal parallax scene</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}
.intro,.outro{height:60vh;display:grid;place-items:center;color:#cfc3c1;text-align:center}
.track{height:320vh}
.pin{position:sticky;top:0;height:100vh;overflow:hidden;background:linear-gradient(#2a0f0b,#e5603a 78%)}
.layer{position:absolute;left:0;bottom:0;display:flex;align-items:flex-end;will-change:transform}
.layer i{flex:none;display:block;clip-path:polygon(50% 0,100% 100%,0 100%)}
.sun{position:absolute;left:62%;top:20%;width:90px;height:90px;border-radius:50%;background:#ffd166;box-shadow:0 0 60px #ffd166}
.ground{position:absolute;left:0;right:0;bottom:0;height:12%;background:#1f0f0e}
</style></head>
<body>
<div class="intro"><p>Scroll down.<br>The landscape will pan sideways.</p></div>
<section class="track" id="track"><div class="pin" id="pin">
<div class="sun"></div>
<div class="ground"></div>
</div></section>
<div class="outro"><p>Journey over.</p></div>
<script>
const track = document.getElementById('track');
const pin = document.getElementById('pin');
// A tiny seeded random so the scene is the same every time
let seed = 7;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
// speed = how much of the full travel this layer moves (far = small, near = large)
const layers = [
{ n: 7, w: 330, h: [260, 380], color: '#4e1408', speed: 0.25 },
{ n: 12, w: 220, h: [150, 260], color: '#3a0804', speed: 0.5 },
{ n: 22, w: 120, h: [90, 190], color: '#1f0f0e', speed: 1 },
].map(cfg => {
const el = document.createElement('div');
el.className = 'layer';
for (let i = 0; i < cfg.n; i++) {
const t = document.createElement('i');
t.style.width = cfg.w + 'px';
t.style.height = cfg.h[0] + rnd() * (cfg.h[1] - cfg.h[0]) + 'px';
t.style.background = cfg.color;
t.style.marginRight = -cfg.w * 0.25 + 'px';
el.append(t);
}
pin.append(el);
return { el, speed: cfg.speed };
});
pin.append(pin.querySelector('.ground')); // keep the ground on top of the layers
function update() {
const scrollable = track.offsetHeight - innerHeight;
const p = Math.min(Math.max(-track.getBoundingClientRect().top / scrollable, 0), 1);
layers.forEach(l => {
const travel = Math.max(l.el.scrollWidth - innerWidth, 0) * l.speed;
l.el.style.transform = `translate3d(${-p * travel}px, 0, 0)`;
});
}
addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
addEventListener('resize', update);
update();
</script>
</body></html>
Good for
Storytelling sections, timelines and playful hero scenes.
Watch out for
Give each layer a different travel distance. The pinned wrapper height decides how long the journey feels.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.