Pinned horizontal scroll
A section pins to the screen while vertical scrolling slides a row of panels sideways.
CSSJavaScriptAdvanced
<div class="intro"><p>Scroll down.<br>The row will slide sideways.</p></div>
<section class="track" id="track">
<div class="pin"><div class="row" id="row">
<div class="panel">1</div><div class="panel">2</div><div class="panel">3</div><div class="panel">4</div><div class="panel">5</div>
</div></div>
</section>
<div class="outro"><p>Back to normal scrolling.</p></div>
body{display:block;height:auto}
.intro,.outro{height:70vh;display:grid;place-items:center;color:#cfc3c1;text-align:center}
.track{position:relative;height:300vh} /* more height = slower horizontal travel */
.pin{position:sticky;top:0;height:100vh;overflow:hidden;display:flex;align-items:center}
.row{display:flex;gap:20px;padding:0 10vw;will-change:transform}
.panel{flex:none;width:min(300px,70vw);height:min(300px,60vh);border-radius:18px;display:grid;place-items:center;font-size:3rem;font-weight:800;color:#270100}
.panel:nth-child(1){background:#f08a5d}.panel:nth-child(2){background:#f3d9c8}.panel:nth-child(3){background:#e8b04c}
.panel:nth-child(4){background:#d98a8a}.panel:nth-child(5){background:#b07a6a}
const track = document.getElementById('track');
const row = document.getElementById('row');
function update() {
const rect = track.getBoundingClientRect();
const scrollable = track.offsetHeight - innerHeight; // distance the pin stays stuck
const progress = Math.min(Math.max(-rect.top / scrollable, 0), 1);
const travel = row.scrollWidth - innerWidth; // distance to slide
row.style.transform = `translate3d(${-progress * 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>Pinned horizontal scroll</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:70vh;display:grid;place-items:center;color:#cfc3c1;text-align:center}
.track{position:relative;height:300vh} /* more height = slower horizontal travel */
.pin{position:sticky;top:0;height:100vh;overflow:hidden;display:flex;align-items:center}
.row{display:flex;gap:20px;padding:0 10vw;will-change:transform}
.panel{flex:none;width:min(300px,70vw);height:min(300px,60vh);border-radius:18px;display:grid;place-items:center;font-size:3rem;font-weight:800;color:#270100}
.panel:nth-child(1){background:#f08a5d}.panel:nth-child(2){background:#f3d9c8}.panel:nth-child(3){background:#e8b04c}
.panel:nth-child(4){background:#d98a8a}.panel:nth-child(5){background:#b07a6a}
</style></head>
<body>
<div class="intro"><p>Scroll down.<br>The row will slide sideways.</p></div>
<section class="track" id="track">
<div class="pin"><div class="row" id="row">
<div class="panel">1</div><div class="panel">2</div><div class="panel">3</div><div class="panel">4</div><div class="panel">5</div>
</div></div>
</section>
<div class="outro"><p>Back to normal scrolling.</p></div>
<script>
const track = document.getElementById('track');
const row = document.getElementById('row');
function update() {
const rect = track.getBoundingClientRect();
const scrollable = track.offsetHeight - innerHeight; // distance the pin stays stuck
const progress = Math.min(Math.max(-rect.top / scrollable, 0), 1);
const travel = row.scrollWidth - innerWidth; // distance to slide
row.style.transform = `translate3d(${-progress * travel}px, 0, 0)`;
}
addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
addEventListener('resize', update);
update();
</script>
</body></html>
Good for
Product tours, timelines and image galleries inside a long page.
Watch out for
Make the tall wrapper exactly as tall as the distance to travel plus one screen. Provide a normal vertical layout on small screens if the content is dense.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.