cookbookcanonicalOWNER-2026-07-10

Cookbook — Lenis (Smooth Scroll) · cb-lenis

Owner directive (2026-07-10), canonical. "generate with the Holonprint Loop with gaussian splatting, lenis, tailwind, react, next … scroll video control." [src: wiki/development/prompts-archive/by-topic/knowledge-management-and-concept-modeling.md:498]

Official docs

itempinreference
lenis1.3.25 [src: HOS-Instance/packages/engine/package.json:19]https://github.com/darkroomengineering/lenis

Our proven patterns

  1. gsap.ticker drives lenis.raf. One clock: gsap.ticker.add((t) => lenis.raf(t * 1000)) with gsap.ticker.lagSmoothing(0) — never a second rAF loop competing with the renderer. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:76]
  2. Emit progress from the Lenis scroll event, not from a scrub trigger. pageProgress = l.scroll / l.limit; Lenis's lerp already provides the scrub-lag feel. An animation-less scrub ScrollTrigger does not reliably fire onUpdate (the Lenis-emit gotcha). [src: HOS-Instance/packages/engine/src/engine/scroll.ts:60]
  3. Reduced motion: never create Lenis. With prefers-reduced-motion, native scroll feeds the same progress emitter via a passive scroll listener — identical events, no smoothing. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:64]
  4. scrollToChapter dual path. Smooth path uses lenis.scrollTo(el, { offset, duration, immediate }); the reduced path computes the rect and jumps with behavior: 'auto'. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:100]
  5. Scroller resolution in app shells. In both HOS apps the page scrolls inside a nested <main class="flex-1 overflow-y-auto">, NOT window/body — Lenis drives that element and every ScrollTrigger must use it as scroller ([data-scroll-root], main). [src: HOS-Webdoc/web/src/lib/scroll/scroller.ts:1]
  6. QA hook convention. Expose the controller on window (window.lenis / window.__st) so headless QA can read internal progress and listener counts. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:86]
  7. Tuning that shipped: duration: 1.15, exponential ease-out, smoothWheel: true, touchMultiplier: 1.4. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:66]

Gotchas

  • Standalone spatial sites (Vite, window-scrolled) and app shells (Next, nested-<main>-scrolled) resolve scrollers differently — a module hard-coding window breaks inside the shell. Always resolve via resolveScroller() there. [src: HOS-Webdoc/web/src/lib/scroll/scroller.ts:14]
  • Guard l.limit > 0 before dividing — a not-yet-laid-out page has limit 0. [src: HOS-Instance/packages/engine/src/engine/scroll.ts:74]

Budgets

Scroll-driven work must stay inside the frame budget of bm-001-web-perf.

Composition

See also

Linked from — 14 cards