# Smooth Scroll — Lenis + GSAP ScrollTrigger

> **Scope:** frontend-scroll-driven
> **Layer:** 3
> **Keywords:** lenis, smooth-scroll, scrolltrigger, raf, gsap ticker, window.__lenis, HMR, scroll wiring
> **Load When:** ui-designer active, or the project uses smooth scrolling with scroll-driven animation

**Verified against:** Lenis 1.x + GSAP 3.13 ScrollTrigger + Vite 5, shipped to production. Last-verified: 2026-08-21.

---

Wiring guide for pairing a smooth-scroll library with scroll-driven animation. Getting this wrong produces animations that lag behind the scroll, triggers that measure the wrong positions, and sections that silently die after a few hot reloads. Covers the RAF handoff, why the instance must be exposed globally, refresh timing, and the HMR staleness trap.

**Which motion stack this is.** Reach for GSAP plus ScrollTrigger plus Lenis when the scroll position drives the scene: frame scrubs, pinned sequences, parallax, progress. For component-level motion in a regular app or product UI (enter and exit, gestures, mount and unmount transitions), use Motion for React and see `frontend/nextjs/motion-patterns.md`. The two can coexist on a page but never on the same element, because both write inline styles and will fight for control. On a page running Lenis, scroll reveals belong to `useReveal` in `frontend/scroll-driven/scroll-components.md`, not to `whileInView`: two libraries observing the same scroll produce two sources of truth for when an element is visible.

---

## Section 1: Central Motion Registry

Register plugins once, in a single module every component imports from. Never register inside `useEffect` or a loop.

```js
// lib/motion.js
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

export const prefersReducedMotion =
  typeof window !== 'undefined' && window.matchMedia &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches

export { gsap, ScrollTrigger }
```

Exporting `prefersReducedMotion` from the same module keeps every effect checking the same value instead of re-querying `matchMedia` with slightly different strings.

---

## Section 2: The RAF Handoff

Lenis and GSAP each want to own the animation frame. Running both tickers independently makes scrub visibly lag the scroll. Drive Lenis from the GSAP ticker instead:

```js
useEffect(() => {
  if (prefersReducedMotion) return

  const lenis = new Lenis({
    duration: 1.1,
    easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
    smoothWheel: true,
  })

  lenis.on('scroll', ScrollTrigger.update)

  // See Section 3.
  if (typeof window !== 'undefined') window.__lenis = lenis

  const tick = (time) => lenis.raf(time * 1000)
  gsap.ticker.add(tick)
  gsap.ticker.lagSmoothing(0)

  // Recalculate once fonts and images have settled.
  const onLoad = () => ScrollTrigger.refresh()
  window.addEventListener('load', onLoad)

  return () => {
    window.removeEventListener('load', onLoad)
    gsap.ticker.remove(tick)
    lenis.destroy()
  }
}, [])
```

Three details carry weight:

- **`lenis.on('scroll', ScrollTrigger.update)`** keeps triggers in sync with smoothed position rather than raw native scroll.
- **`gsap.ticker.lagSmoothing(0)`** disables GSAP's frame-drop compensation, which otherwise fights the smooth scroller after a stall.
- **`ScrollTrigger.refresh()` on `load`** fixes trigger positions measured before webfonts and images changed the layout height.

---

## Section 3: Expose the Instance on `window`

Set `window.__lenis` deliberately. It is not a debugging leftover, it serves two production needs:

1. **Overlays must stop the scroll.** Modals and lightboxes call `window.__lenis?.stop()` on open and `.start()` on close, otherwise the page scrolls behind the overlay.
2. **Verification needs to drive the scroll.** Automated checks (Playwright, Chrome DevTools MCP) use `lenis.scrollTo(y, { immediate: true })` to step through scroll positions and capture what actually renders. See `frontend/scroll-driven/frame-scrub.md`, Section 8.

Consumers must use optional chaining (`window.__lenis?.stop()`), because under reduced motion the instance is never created.

**`stop()` alone breaks touch scrolling inside the overlay.** The smooth scroller listens to `touchmove` globally, and while stopped it calls `preventDefault()` on any touch that bubbles up to it. An overlay panel with `overflow-y: auto` scrolls fine with a mouse wheel and is completely frozen on a phone, which usually hides whatever sits at the bottom of the panel, such as the CTA. Mark the scrollable element with the opt-out attribute Lenis provides for exactly this case:

```jsx
<div className="modal__panel" data-lenis-prevent>
  {/* long content, native overflow-y: auto */}
</div>
```

Wheel and touch are separate code paths here, so a desktop check does not cover this. Verify the overlay on a touch viewport.

---

## Section 4: The HMR Staleness Trap

**Symptom:** after many hot reloads in dev, pins stop holding, scrubs freeze, a pinned horizontal gallery stops locking.

**Cause:** ScrollTrigger accumulates stale instances across reloads. In most cases the code is fine.

**First response:** do a full page reload (F5) before debugging. It clears the accumulated state. Concluding that a section is broken based on a hot-reloaded page wastes real time.

**Defenses in code:**

- `invalidateOnRefresh: true` on any trigger whose measurements depend on viewport size (pinned horizontal distance, computed end values).
- Wrap every effect in `gsap.context()` or `gsap.matchMedia()` and revert it on cleanup, so hot reloads cannot duplicate triggers.
- Call `ScrollTrigger.refresh()` on `load` and after killing responsive tweens.

```js
useEffect(() => {
  const ctx = gsap.context(() => {
    // tweens and triggers here
  }, scopeRef)
  return () => ctx.revert()   // non-negotiable
}, [])
```

---

## Section 5: Reduced Motion

Under `prefers-reduced-motion: reduce`, do not instantiate the smooth scroller at all. Native scrolling is the accessible baseline, and every scroll-driven effect must have a static fallback rather than a degraded animation.

This means `window.__lenis` is `undefined` in that path, which is exactly why Section 3 requires optional chaining.

---

## Checklist

- [ ] Plugins registered once in a shared `lib/motion` module
- [ ] Lenis driven from `gsap.ticker`, not its own RAF loop
- [ ] `lagSmoothing(0)` set
- [ ] `ScrollTrigger.update` bound to the Lenis scroll event
- [ ] `ScrollTrigger.refresh()` called on window `load`
- [ ] `window.__lenis` exposed, and every consumer uses optional chaining
- [ ] Every effect wrapped in `gsap.context()` / `matchMedia()` with revert on cleanup
- [ ] `invalidateOnRefresh: true` on viewport-dependent triggers
- [ ] Scrollable overlay panels marked `data-lenis-prevent` and verified on a touch viewport
- [ ] Smooth scroller not instantiated under reduced motion
