Image window parallax
Images sit inside a fixed frame and slide slowly within it as the frame scrolls past, like looking through a window.
CSSJavaScriptIntermediate
<p class="hint">Scroll inside this frame</p>
<div class="col">
<div class="frame"><div class="img i1" data-speed="0.14"></div></div>
<div class="frame"><div class="img i2" data-speed="0.14"></div></div>
<div class="frame"><div class="img i3" data-speed="0.14"></div></div>
<div class="frame"><div class="img i1" data-speed="0.14"></div></div>
</div>
body{display:block;height:auto;padding:30px 0 80px}
.col{width:min(440px,88vw);margin:0 auto;display:grid;gap:36px}
.hint{text-align:center;color:#a6938f;font-size:14px}
.frame{position:relative;height:240px;overflow:hidden;border-radius:16px}
.frame .img{position:absolute;left:0;right:0;top:-18%;height:136%;will-change:transform}
.i1{background:radial-gradient(circle at 70% 35%,#ffd166 0 7%,transparent 8%),linear-gradient(170deg,#e5603a,#8a3a22 70%)}
.i2{background:radial-gradient(circle at 30% 55%,#f08a5d 0 6%,transparent 7%),linear-gradient(170deg,#6e2a18,#2a0f0b)}
.i3{background:radial-gradient(circle at 60% 45%,#fff 0 5%,transparent 6%),linear-gradient(170deg,#d98a8a,#4e1408)}
const imgs = [...document.querySelectorAll('[data-speed]')];
function update() {
const vh = innerHeight;
imgs.forEach(img => {
const r = img.parentElement.getBoundingClientRect(); // measure the frame, not the image
if (r.bottom < 0 || r.top > vh) return; // skip what is off screen
const fromCenter = r.top + r.height / 2 - vh / 2; // 0 when the frame is centred
img.style.transform = `translate3d(0, ${fromCenter * -img.dataset.speed}px, 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>Image window 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{display:block;place-items:normal;height:auto;padding:30px 0 80px}
.col{width:min(440px,88vw);margin:0 auto;display:grid;gap:36px}
.hint{text-align:center;color:#a6938f;font-size:14px}
.frame{position:relative;height:240px;overflow:hidden;border-radius:16px}
.frame .img{position:absolute;left:0;right:0;top:-18%;height:136%;will-change:transform}
.i1{background:radial-gradient(circle at 70% 35%,#ffd166 0 7%,transparent 8%),linear-gradient(170deg,#e5603a,#8a3a22 70%)}
.i2{background:radial-gradient(circle at 30% 55%,#f08a5d 0 6%,transparent 7%),linear-gradient(170deg,#6e2a18,#2a0f0b)}
.i3{background:radial-gradient(circle at 60% 45%,#fff 0 5%,transparent 6%),linear-gradient(170deg,#d98a8a,#4e1408)}
</style></head>
<body>
<p class="hint">Scroll inside this frame</p>
<div class="col">
<div class="frame"><div class="img i1" data-speed="0.14"></div></div>
<div class="frame"><div class="img i2" data-speed="0.14"></div></div>
<div class="frame"><div class="img i3" data-speed="0.14"></div></div>
<div class="frame"><div class="img i1" data-speed="0.14"></div></div>
</div>
<script>
const imgs = [...document.querySelectorAll('[data-speed]')];
function update() {
const vh = innerHeight;
imgs.forEach(img => {
const r = img.parentElement.getBoundingClientRect(); // measure the frame, not the image
if (r.bottom < 0 || r.top > vh) return; // skip what is off screen
const fromCenter = r.top + r.height / 2 - vh / 2; // 0 when the frame is centred
img.style.transform = `translate3d(0, ${fromCenter * -img.dataset.speed}px, 0)`;
});
}
addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
addEventListener('resize', update);
update();
</script>
</body></html>
Good for
Editorial photo sections, article headers and portfolio cards.
Watch out for
Make the image taller than the frame so there is room to move. Measure from the frame (the parent), never the moving image, to avoid feedback.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.