Web Animations Medium
Pinned Horizontal Gallery
Pin a section while vertical scroll drives a horizontal GSAP gallery strip with Lenis-smoothed scrolling.
Open in Lab
MCP
html css gsap lenis javascript
Targets: JS HTML
Code
[hidden] {
display: none !important;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #08080c;
color: #eee;
}
.intro,
.outro {
min-height: 60vh;
display: grid;
place-content: center;
padding: 2rem;
text-align: center;
}
.intro h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.75rem, 4vw, 2.5rem);
}
.intro p,
.outro p {
margin: 0;
color: #888;
}
.pin-wrap {
height: 100vh;
}
.pin-viewport {
height: 100vh;
overflow: hidden;
background: #0f0f14;
}
.track {
display: flex;
height: 100%;
width: max-content;
will-change: transform;
}
.slide {
position: relative;
flex: 0 0 100vw;
height: 100%;
padding: 2rem;
display: grid;
place-items: center;
}
.slide img {
width: min(72vw, 720px);
height: min(52vh, 480px);
object-fit: cover;
border-radius: 12px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
}
.slide span {
position: absolute;
bottom: 3rem;
left: 3rem;
font-size: 0.75rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #aaa;
}const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const track = document.querySelector(".track");
const slides = document.querySelectorAll(".slide");
gsap.registerPlugin(ScrollTrigger);
function setupHorizontal() {
const totalScroll = track.scrollWidth - window.innerWidth;
if (totalScroll <= 0) return;
gsap.to(track, {
x: () => -totalScroll,
ease: "none",
scrollTrigger: {
trigger: ".pin-wrap",
start: "top top",
end: () => `+=${totalScroll}`,
pin: ".pin-viewport",
scrub: reduced ? false : 0.8,
invalidateOnRefresh: true,
},
});
}
function bootLenis() {
if (reduced || typeof Lenis === "undefined") {
setupHorizontal();
ScrollTrigger.refresh();
return;
}
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
setupHorizontal();
ScrollTrigger.refresh();
}
bootLenis();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pinned Horizontal Gallery</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<section class="intro">
<h1>Scroll vertically</h1>
<p>The gallery moves sideways while this block stays pinned.</p>
</section>
<section class="pin-wrap">
<div class="pin-viewport">
<div class="track">
<article class="slide"><img src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&q=80" alt="" /><span>Mist</span></article>
<article class="slide"><img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=900&q=80" alt="" /><span>Alpine</span></article>
<article class="slide"><img src="https://images.unsplash.com/photo-1491553895911-0055eca6402d?w=900&q=80" alt="" /><span>Coast</span></article>
<article class="slide"><img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?w=900&q=80" alt="" /><span>Peaks</span></article>
<article class="slide"><img src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=900&q=80" alt="" /><span>Surf</span></article>
</div>
</div>
</section>
<section class="outro">
<p>End of gallery — keep scrolling.</p>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.1.18/dist/lenis.min.js"></script>
<script src="script.js"></script>
</body>
</html>Pinned Horizontal Gallery
Vertical scroll is converted into horizontal translation inside a pinned viewport — a common editorial pattern for lookbooks and case studies.
When to use it
- Photo galleries and product rails that should feel scroll-driven
- Story chapters where each panel is full viewport width