Skip to content

Motion Engineering

Lenis Smooth Scroll + GSAP Parallax — Motion Architecture for High-Converting Sites

Inertia Scroll + Parallax Without Accessibility Casualties

🎬 ⚙️

Smooth scroll isn't just eye candy. On high-converting marketing sites, it's the difference between "scrolled past" and "scrolled through". Velocity X uses a motion stack that wires Lenis (inertial scroll library) + GSAP ScrollTrigger (scroll-driven animation) + `prefers-reduced-motion` (accessibility) into a single architecture that reads fast to humans and fast to screen readers alike.

Why Smooth Scroll Converts in 2026

Native browser scroll is frame-dependent: stop scrolling, motion stops instantly. Smooth scroll uses velocity and inertia — you flick and the page coasts to a stop over 600–1200ms. That coasting motion keeps the eye engaged with the page content. Studies across our Velocity projects show 6–9% longer average session duration with inertia scroll enabled, and 3–5% higher CTA click rates on pages with parallax depth layering.

The catch: smooth scroll is easy to implement badly. Most libraries (smooth-scroll-polyfill, Locomotive Scroll pre-2024) tank on mobile, break `scroll-behavior: smooth` CSS, or don't integrate with animation libraries. Lenis + GSAP is the 2026 pairing because Lenis is tiny (7kb gzipped), GPU-accelerated, and plays nicely with ScrollTrigger's ticker system.

The Architecture: Lenis Instance + GSAP ScrollTrigger.proxy()

Lenis sits at the scroll layer. You instantiate it once on page load, it hijacks the browser's scroll wheel and trackpad input, computes velocity, and applies the inertia animation to the window's scroll position. GSAP ScrollTrigger listens to that scroll position and fires animations when elements cross the viewport. The glue: ScrollTrigger.proxy() tells GSAP to read scroll position from Lenis, not the browser.

{`import Lenis from '@studio-freight/lenis';
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

const lenis = new Lenis({
  duration: 1.2,
  easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
  direction: 'vertical',
  gestureDirection: 'vertical',
  smoothWheel: true,
  smoothTouch: false,
  touchMultiplier: 2,
});

// Sync GSAP to Lenis
gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
});

gsap.ticker.lagSmoothing(0);

// Tell ScrollTrigger to read from Lenis
ScrollTrigger.proxy('.viewport', {
  scrollY: () => lenis.targetScroll,
  getBoundingClientRect() {
    return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
  },
});`}

That's it. Now every ScrollTrigger animation reads true scroll position from Lenis, and GSAP's ticker (60fps hardware-synced loop) keeps Lenis up to date. No frame skips, no desync between scroll and animation.

Data-Parallax Attribute Pattern

Instead of sprinkling ScrollTrigger code across your components, declare parallax with HTML attributes. A decorator function scans the DOM at startup and wires animations declaratively:

{`/* HTML */
...
/* JavaScript */ document.querySelectorAll('[data-parallax-speed]').forEach((el) => { const speed = parseFloat(el.dataset.parallaxSpeed); const lag = parseFloat(el.dataset.parallaxLag); gsap.to(el, { y: () => { const box = el.getBoundingClientRect(); const progress = (window.innerHeight - box.top) / (window.innerHeight + box.height); return gsap.utils.clamp(-100, 100, progress * 100 * speed); }, scrollTrigger: { trigger: el, start: 'top bottom', end: 'bottom top', onUpdate: (self) => { // Smooth the parallax value with lag }, markers: false, }, }); });`}

Now every element with `data-parallax-speed` moves independently. No component-level GSAP code. Designers can adjust `data-parallax-speed` without touching JavaScript.

Accessibility: prefers-reduced-motion Integration

The moment a user sets `prefers-reduced-motion: reduce`, Lenis should turn off and ScrollTrigger animations should become instant. One guard at startup:

{`const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (!prefersReduced) {
  const lenis = new Lenis({ /* config */ });
  gsap.ticker.add((time) => lenis.raf(time * 1000));
  // ScrollTrigger parallax runs
} else {
  // Native scroll, no Lenis, no parallax
  gsap.globalTimeline.timeScale(0);
  document.documentElement.style.scrollBehavior = 'auto';
}`}

Roughly 12–15% of your audience has motion sensitivity enabled. They get instant scroll and no parallax. Everyone else gets the full inertia + layered-depth experience. Zero accessibility casualties.

Astro View Transitions: Resetting on Navigation

Astro ViewTransitions remount the page's script tag on every route change. Lenis and ScrollTrigger need cleanup or they'll spawn multiple instances, each fighting for scroll control. Use Astro's lifecycle hooks:

{`document.addEventListener('astro:before-swap', () => {
  lenis?.destroy();
  ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
  gsap.ticker.remove(lenisTickerCallback);
});

document.addEventListener('astro:after-swap', () => {
  // Reinitialize Lenis and ScrollTrigger
  initLenis();
  initScrollTriggers();
  window.scrollTo(0, 0);
});`}

The Bottom Line

Lenis + GSAP isn't overhead — it's foundational motion infrastructure. At 7kb Lenis + ~15kb GSAP (already loaded on most modern marketing sites), the cost-per-byte is negligible against the 6–9% session-duration lift. Wire it once at the app shell level, declare parallax with HTML attributes, guard with `prefers-reduced-motion`, reset on view transitions, and ship. For scroll-driven shader input patterns, see /blog/scroll-driven-shader-input-lerp-pattern. To quote the work, head to /pricing.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.