Pinned Scroll example
How it works
One handler, two ways to use it: presets you configure on the HTML, and custom animations you write in JS.
PinnedScroll pins a section while the user scrolls through it and animates what is inside along the way. The scroll position drives the animation: scroll down and it plays, scroll back up and it rewinds. It is built on GSAP ScrollTrigger, taken from the Manager like every other library in the framework.
There are two modes, and one rule tells them apart: presets live on the HTML, custom animations live in JS. If a config has build, it is custom and the preset options are ignored.
Presets
Ready-made effects: stack, fade, slide, zoom. Pick one and tune it with data-pinned-* attributes on the container. No JS to write.
<!-- The container carries the hook and the options. Each panel is a js--pinned-scroll-item -->
<div class="c--pinned-scroll-a js--pinned-scroll" data-pinned-effect="fade">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="d">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="color-f">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="#ffb41d">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="/img/cover.webp">…</section>
</div> Custom
A config with a build function in Handler.js. The class pins the section and hands you a timeline; what goes in it is up to you.
// A config with `build` is a custom animation. The class pins the section and hands you the
// timeline; the effect options and the panels are not looked at
this.configReveal = ({ element }) => ({
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
duration: 2,
build: ({ timeline, element, gsap }) => {
const title = element.querySelector(".js--pinned-scroll-title");
gsap.set(title, { autoAlpha: 0, y: 40 });
timeline.to(title, { autoAlpha: 1, y: 0, duration: 0.4 });
},
}); The markup
A container with the component class and the js--pinned-scroll hook, and one js--pinned-scroll-item per panel. Each panel can carry a data-background: the section blends to it as the panel comes into view. The value is a color token of the foundation (c or color-c for $color-c in _vars.scss, resolved through the f--color-* classes, nothing to add), any CSS color, or a picture (url(…) or an image path), which crossfades. The class only writes CSS variables on the container; the SCSS decides what it paints.
The handler
Like every handler in Punky, Handler.js maps selectors to configs and lets the framework handle the lifecycle. The preset config only reads the data attributes and passes them on; a custom config is the same shape plus build, and each custom animation gets its own selector, exactly like the Slider variations:
this.config = ({ element }) => {
const { pinnedEffect, pinnedIntensity, pinnedSpeed, pinnedDirection } = element.dataset;
return {
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
effect: pinnedEffect || "stack", // stack | fade | slide | zoom
intensity: pinnedIntensity || "medium", // soft | medium | strong
speed: pinnedSpeed || "normal", // slow | normal | fast
direction: pinnedDirection || "up", // slide: up | down | left | right · zoom: in | out
};
}; The class
PinnedScroll.js owns the mechanics. For presets it builds the pins and tweens of the chosen effect; for custom it builds one pinned, scrubbed timeline and calls your build. Everything is created inside a gsap.matchMedia context, which is what makes the next list free.
What the framework does for you
- Lazy loading. A section is only built when it gets close to the viewport (Boostify). The GSAP libraries are imported once and cached by the Manager.
- Page transitions. Every instance is destroyed before swup replaces the content and rebuilt on the new page. Pins, tweens and inline styles are reverted.
- Reduced motion. With
prefers-reduced-motionnothing pins or animates: a preset is a plain stack of panels, a custom section shows its content as is. - Debug. Add
?debugto the URL to see the ScrollTrigger markers of every pin. - Anchors. AnchorTo loads every pinned section before it scrolls, so an anchor below them lands in the right place. The chapter strip under the hero uses it.
Stack
The default. Each panel pins at the center and the next one climbs over it.
The panels stay in the document flow. When a panel reaches the center of the viewport it pins there, and the next one travels up over it, growing from small to full size as it arrives. The panel being covered shrinks and blurs. Because the pace comes from the panel height set in SCSS, speed has no effect here; intensity sets how small a panel starts and how blurred it gets when covered.
Nothing to configure for the default. Add data-pinned-intensity to change the travel:
<div class="c--pinned-scroll-a js--pinned-scroll">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="a">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="d" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="e" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="f" style="background-image: url(…)"></section>
</div> Stack
The default: nothing to configure
Fade
The section pins and the panels fade one into the next, never two on screen at once.
effect="fade"speed="slow" Built for copy. The panels sit in the same spot and the whole section pins while the scroll swaps them: the current panel fades out and drifts up, the background blends while nothing is visible, and the next panel fades in from below. The scroll snaps to the nearest panel when it stops mid-swap, so a section never rests between two texts.
speed is how much scroll each panel owns. Here it is slow, which gives the reader more room per text:
<div class="c--pinned-scroll-a js--pinned-scroll" data-pinned-effect="fade" data-pinned-speed="slow">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="d">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="color-f">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="g">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="#f78bd8">…</section>
</div> effect="fade"speed="slow" Lorem ipsum dolor sit amet consectetur adipisicing elit.
Dolorum aperiam totam expedita harum deleniti voluptate sint perspiciatis.
Explicabo mollitia distinctio, quam tempore similique ipsa consequatur.
Laboriosam, quibusdam. Vitae quos doloribus maiores.
Slide
The section pins and each panel pushes the previous one out.
effect="slide"direction="left"speed="fast" Same pin as fade, but both panels move at once: the current one leaves through one side while the next one comes in through the other, like a single strip being dragged. direction is where the panels go: up, down, left or right. The panels travel by the width or height of the container, so a waiting panel never peeks into the margin.
<div class="c--pinned-scroll-a js--pinned-scroll" data-pinned-effect="slide" data-pinned-direction="left" data-pinned-speed="fast">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="a">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="d" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="e" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="f" style="background-image: url(…)"></section>
</div> effect="slide"direction="left"speed="fast" Slide
To the left, at the fast pace
Zoom
The section pins and each panel settles into place through depth: blurred and oversized when near, small and sharp when far.
effect="zoom"direction="out | in"intensity="strong | soft" Zoom has two states for a panel that is away: near (oversized and blurred, as if too close to the camera) and far (small and sharp). direction picks which way the scroll travels through them:
out(default): the next panel arrives from near and settles into place, the current one falls back to far. The panels shrink as you scroll on.in: the next panel grows into place from far, the current one flies past up close. The panels grow as you scroll on.
intensity sets how far away both states are. Two live examples, one per direction. The second one also mixes pictures and colors in data-background: the section crossfades to a picture behind two of its panels.
<div class="c--pinned-scroll-a js--pinned-scroll" data-pinned-effect="zoom" data-pinned-intensity="strong">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="a">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="g" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="#f78bd8" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="f" style="background-image: url(…)"></section>
</div> <div class="c--pinned-scroll-a js--pinned-scroll" data-pinned-effect="zoom" data-pinned-direction="in" data-pinned-intensity="soft">
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="a">…</section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="http://placeholder.terrahq.com/img-16by9.webp" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="d" style="background-image: url(…)"></section>
<section class="c--pinned-scroll-a__item js--pinned-scroll-item" data-background="http://placeholder.terrahq.com/img-1by1.webp" style="background-image: url(…)"></section>
</div> effect="zoom"direction="out | in"intensity="strong | soft" Zoom out
Strong intensity: the biggest start and the heaviest blur
Zoom in
Soft intensity: a short travel and a light blur
Custom animation
A config with a build function. The class pins the section and gives you the timeline; the animation is yours.
When a preset is not what the design asks for, write the animation. Add a config with a build function to Handler.js and give the section its own js-- hook. The class pins the section for duration viewports, creates a timeline scrubbed by the scroll, and calls build with it. The data attributes and the panels are not looked at, so the HTML carries nothing but the hooks your animation needs.
The config
this.configExample = ({ element }) => ({
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
duration: 2, // viewports of scroll the section stays pinned for. Default 2
scrub: 1, // seconds the timeline takes to catch up with the scroll. 0 is instant. Default 1
scrollTrigger: { // optional: any ScrollTrigger option, merged over the pin defaults
start: "center center",
},
// The animation. Everything you add to `timeline` is scrubbed across the whole pin,
// so tween durations are relative units, not seconds
build: ({ timeline, element, gsap }) => {
// element: the pinned section. gsap: ready to use
},
}); Three things to keep in mind inside build:
- Durations are relative. The whole timeline is stretched across the pin, so a tween with
duration: 1next to one withduration: 3takes a quarter of the scroll. A trailing empty tween,.to({}, { duration: 0.5 }), holds the finished state before the pin lets go. - Set the start state in build. Any
gsap.setyou run there is recorded by the matchMedia context and reverted on destroy and under reduced motion, so the page never keeps stale inline styles. - The timeline can drive anything. CSS properties, CSS variables such as
--pinned-scroll-bg, or a plain object whose values you write into the DOM ononUpdate. The counter below does the last one.
Changing the pin itself
The pin is a ScrollTrigger with sensible defaults. The scrollTrigger option is spread over them last, so it is a merge: name only the keys you want to change, the rest stay. If you pass end, duration no longer applies, since that is what it computes.
// What the class builds. Your scrollTrigger object is spread last, so you only name what changes
scrollTrigger: {
trigger: element,
start: "top top",
end: () => `+=${this.duration * window.innerHeight}`,
invalidateOnRefresh: true,
pin: true,
anticipatePin: 1,
scrub: this.scrub,
markers: this.terraDebug,
...this.scrollTrigger,
} Reading the ScrollTrigger from inside build is possible through timeline.scrollTrigger, for its progress or direction. Changing how the pin behaves always goes through the scrollTrigger option.
Adding one
Three touches in Handler.js, nothing in resources.js or Main.js:
// 1. The config, in the constructor
this.configHero = ({ element }) => ({ element, Manager: this.Manager, terraDebug: this.terraDebug, build: … });
// 2. Its selector, in updateTheDOM
heroElements: document.querySelectorAll(".js--pinned-scroll-hero"),
// 3. Its entry, in elementGroups
{ elements: this.DOM.heroElements, config: this.configHero, boostify: { distance: 100 } }, Responsive
Presets have no per-breakpoint options, so a section that should move differently on mobile is a custom one. There are two ways, depending on how much changes.
// Same choreography, different values: function-based values are read again on every
// refresh, and the pin already has invalidateOnRefresh, so a resize picks up the change
build: ({ timeline, element, gsap }) => {
const isDesktop = () => window.innerWidth >= 1024;
const lines = element.querySelectorAll(".js--pinned-scroll-line");
gsap.set(lines, { autoAlpha: 0, y: () => (isDesktop() ? 80 : 30) });
timeline.to(lines, { autoAlpha: 1, y: 0, duration: 0.4, stagger: () => (isDesktop() ? 0.2 : 0.1) });
}, // Different choreographies: a matchMedia inside build. Each branch fills the timeline and
// clears it on the way out, so crossing the breakpoint swaps one for the other
build: ({ timeline, element, gsap }) => {
const media = element.querySelector(".js--pinned-scroll-media");
const copy = element.querySelector(".js--pinned-scroll-line");
const mm = gsap.matchMedia();
mm.add("(min-width: 1024px)", () => {
gsap.set(media, { xPercent: -100 });
gsap.set(copy, { xPercent: 100, autoAlpha: 0 });
timeline
.to(media, { xPercent: 0, duration: 0.5 })
.to(copy, { xPercent: 0, autoAlpha: 1, duration: 0.5 }, "<0.2");
return () => timeline.clear();
});
mm.add("(max-width: 1023px)", () => {
gsap.set([media, copy], { y: 60, autoAlpha: 0 });
timeline.to([media, copy], { y: 0, autoAlpha: 1, duration: 0.5, stagger: 0.15 });
return () => timeline.clear();
});
}, One limit: the scrollTrigger option is read once, when the section is built, so something like pin: window.innerWidth >= 1024 works on load but does not react to a resize.
Reveal
The hello world of custom. A line grows and the lines of text rise in a cascade.
duration: 2 Every animated child carries a js-- hook, build queries them from the section, sets their start state and chains the tweens. The "<0.1" position starts the text 0.1 units after the line began, and stagger spaces the three lines without three separate tweens.
this.configReveal = ({ element }) => ({
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
duration: 1.25,
build: ({ timeline, element, gsap }) => {
const dash = element.querySelector(".js--pinned-scroll-dash");
const lines = element.querySelectorAll(".js--pinned-scroll-line");
gsap.set(dash, { scaleX: 0, transformOrigin: "left center" });
gsap.set(lines, { autoAlpha: 0, y: 40 });
timeline
.to(dash, { scaleX: 1, duration: 0.1 })
.to(lines, { autoAlpha: 1, y: 0, duration: 0.4, stagger: 0.05 }, "<0.1")
.to({}, { duration: 0.1 }); // holds at the end before the pin lets go
},
}); <section class="c--pinned-scroll-a js--pinned-scroll-reveal">
<div class="c--pinned-scroll-a__wrapper">
<div class="c--pinned-scroll-a__dash js--pinned-scroll-dash"></div>
<span class="c--pinned-scroll-a__badge js--pinned-scroll-line">…</span>
<h2 class="c--pinned-scroll-a__title js--pinned-scroll-line">…</h2>
<p class="c--pinned-scroll-a__content js--pinned-scroll-line">…</p>
</div>
</section> duration: 2 Any animation
The class handles the pin and the scrub. The build callback is yours.
Counter
The timeline drives numbers, not CSS. Three figures count up one after the other while a bar fills across the pin.
duration: 3 The target of each figure comes from its data-count. The tween animates a plain object and writes the rounded value into the DOM on every update, so the number scrubs with the scroll like everything else. With duration: 3 and one unit per figure, each one owns exactly one viewport of scroll.
this.configCounter = ({ element }) => ({
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
duration: 3,
build: ({ timeline, element, gsap }) => {
const loader = element.querySelector(".js--pinned-scroll-loader");
const values = [...element.querySelectorAll(".js--pinned-scroll-value")];
gsap.set(loader, { scaleX: 0, transformOrigin: "left center" });
timeline.to(loader, { scaleX: 1, duration: values.length }, 0);
values.forEach((value, index) => {
const count = { current: 0 };
value.textContent = "0";
timeline.to(
count,
{
current: Number(value.dataset.count) || 0,
duration: 1,
onUpdate: () => {
value.textContent = Math.round(count.current).toLocaleString("en-US");
},
},
index,
);
});
},
}); <section class="c--pinned-scroll-a js--pinned-scroll-counter">
<div class="c--pinned-scroll-a__wrapper">
<div class="c--pinned-scroll-a__loader js--pinned-scroll-loader"></div>
<div class="c--pinned-scroll-a__list-group">
<div class="c--pinned-scroll-a__list-item">
<span class="c--pinned-scroll-a__list-item__title js--pinned-scroll-value" data-count="120">120</span>
<p class="c--pinned-scroll-a__list-item__content">Projects shipped</p>
</div>
…
</div>
</div>
</section> duration: 3 Projects shipped
Lines of SCSS removed
Lighthouse score
Deck
A section shorter than the viewport, pinned once it is centered. Three cards fan out and the background follows.
duration: 2start: "center center" Two things this one shows. The scrollTrigger override: the section is 70dvh tall, so start: "center center" pins it when it is centered instead of when its top hits the viewport. And the color variable: --pinned-scroll-bg is available in custom too, so the timeline writes each card's data-background into it as the card fans out.
this.configDeck = ({ element }) => ({
element,
Manager: this.Manager,
terraDebug: this.terraDebug,
duration: 2,
scrollTrigger: { start: "center center" },
build: ({ timeline, element, gsap }) => {
const [left, middle, right] = element.querySelectorAll(".js--pinned-scroll-card");
gsap.set(element, { "--pinned-scroll-bg": middle.dataset.background });
gsap.set([left, right], { transformOrigin: "center bottom" });
timeline
.to(left, { rotation: -18, xPercent: -30, duration: 0.3 }, 0)
.to(element, { "--pinned-scroll-bg": left.dataset.background, duration: 0.3 }, 0)
.to(right, { rotation: 18, xPercent: 30, duration: 0.3 }, 0.3)
.to(element, { "--pinned-scroll-bg": right.dataset.background, duration: 0.3 }, 0.3)
.to({}, { duration: 0.2 });
},
}); <!-- c--pinned-scroll-b is the compact container: 70dvh, centered content -->
<section class="c--pinned-scroll-b js--pinned-scroll-deck">
<div class="c--pinned-scroll-b__media-wrapper">
<div class="c--pinned-scroll-b__media js--pinned-scroll-card" data-background="#7ce55e" style="background-image: url(…)"></div>
<div class="c--pinned-scroll-b__media js--pinned-scroll-card" data-background="#3d9dd8" style="background-image: url(…)"></div>
<div class="c--pinned-scroll-b__media js--pinned-scroll-card" data-background="#ffb41d" style="background-image: url(…)"></div>
</div>
</section> duration: 2start: "center center" Options reference
Everything you can set, in one place.
Presets (data attributes on the container)
| Attribute | Values | Applies to |
|---|---|---|
data-pinned-effect | stack (default), fade, slide, zoom | all |
data-pinned-intensity | soft, medium (default), strong. How much a panel scales and blurs | stack, zoom |
data-pinned-speed | slow, normal (default), fast. How much scroll each panel owns | fade, slide, zoom |
data-pinned-direction | slide: up (default), down, left, right. zoom: out (default), in | slide, zoom |
data-background | On each panel. A foundation color token (c or color-c), any CSS color, or a picture (url(…) or an image path). The section blends or crossfades to it as the panel comes into view | all |
Custom (config in Handler.js)
| Option | Default | What it does |
|---|---|---|
build | required | ({ timeline, element, gsap }) => {}. Adds the tweens to the pinned, scrubbed timeline |
duration | 2 | Viewports of scroll the section stays pinned for |
scrub | 1 | Seconds the timeline takes to catch up with the scroll. 0 is instant |
scrollTrigger | {} | Any ScrollTrigger option, merged over the pin defaults |