[§] /docs/how-to/scroll-without-hijacking
How to add scroll effects without hijacking the page
Pin, scrub, skew and zoom inside one section, while the rest of the page scrolls normally.
TL;DR
- Scope Lenis to a container, drive it from the GSAP ticker, and mark inner scrollers with
data-lenis-prevent. - Tell ScrollTrigger about the scroller, use
pinType: 'transform', and refresh only that container. - Skew by velocity and zoom exponentially, both clamped.
Goal: one section feels like a film, and the page around it still feels like a web page.
You need
gsapwithScrollTrigger, andlenis.- A container with its own scroll (
overflow-y: auto) and a fixed height.
page: native scroll
┌ section: overflow-y auto ────────────┐
│ Lenis(wrapper) ─ gsap.ticker │
│ ScrollTrigger({ scroller }) │
│ [pin] ── scrub timeline ── [release] │
│ │
│ inner list: data-lenis-prevent │
│ └ scrolls natively again │
└──────────────────────────────────────┘
page: native scroll again
Steps
1. Scope Lenis to the container
const lenis = new Lenis({ wrapper: box, content: box.firstElementChild as HTMLElement, autoRaf: false, lerp: 0.1 });
const tick = (t: number) => lenis.raf(t * 1000);
gsap.ticker.add(tick);
lenis.on('scroll', ScrollTrigger.update);autoRaf: false means GSAP drives Lenis, so both run on one clock. Remove the ticker callback and call
lenis.destroy() on cleanup.
2. Let inner scrollers scroll natively
Add data-lenis-prevent to a code block, a list or a map inside the section. Lenis ignores wheel and
touch events there.
3. Point ScrollTrigger at the container
ScrollTrigger.create({
trigger: chapter, scroller: box, start: 'top top', end: '+=150%',
pin: true, pinType: 'transform', scrub: true, animation: tl,
});The default pin uses position: fixed, which breaks inside a transformed or scrolling container.
pinType: 'transform' pins by moving the element instead.
4. Refresh only your triggers
When images load or the box resizes, refresh the triggers you made, not the whole page:
new ResizeObserver(() => triggers.forEach((t) => t.refresh())).observe(box);ScrollTrigger.refresh() recalculates every trigger on the site, which causes a jump elsewhere.
5. Skew by velocity
const skew = gsap.quickTo(cards, 'skewY', { duration: 0.4, ease: 'power3' });
lenis.on('scroll', ({ velocity }) => skew(gsap.utils.clamp(-8, 8, velocity * 0.4)));Clamp it. Unclamped skew on a fast trackpad flick looks broken.
6. Zoom exponentially
For a Droste or "zoom into the picture" effect, scale by a power, not a line: scale = 2 ** (progress * 6).
Linear zoom feels slow at the start and wild at the end. Exponential zoom feels steady all the way.
7. Give reduced motion a plain scroll
Inside gsap.matchMedia(), create Lenis and the pins only for (prefers-reduced-motion: no-preference).
Reduced motion gets normal scrolling and every chapter shown in its final state.
Why it works
People trust the scroll bar. Taking over one clearly framed section keeps the effect and keeps that trust. The rest of the page, the back button and the keyboard all still behave.
See it live
[ex] in the wild · 17
















