Set up Lenis as the physics-based smooth-scroll foundation for the whole page, and synchronize it correctly with GSAP ScrollTrigger — this is the step that is most often done wrong when combining these two libraries.
WHAT
Purpose and context
- Purpose: set up Lenis as the physics-based smooth-scroll foundation for the WHOLE PAGE and synchronize it correctly with GSAP ScrollTrigger (one raf driver, no phase offset, no jank when returning to the tab), by default using the original library verbatim at
Rheinmir/uiux-asset. - Trigger (when to use):
- The page needs a scroll feel that is "heavy/smooth" in the Apple/awwwards style instead of the browser's default stiff, jerky scroll.
- The page already uses or will use GSAP ScrollTrigger (parallax, reveal, pin) and needs Lenis + ScrollTrigger to NOT conflict (jank, trigger position offset).
- DO NOT use if you only need a few light scroll effects and do not need background smooth-scroll (use
scroll-effects— vanilla, 0 dependency). - DO NOT use to pin/horizontally scroll a separate section (that is
gsap-scrolltrigger-pin) — but if the page has both needs, initialize Lenis according to this skill FIRST, thengsap-scrolltrigger-pinworks normally on top because both use ScrollTrigger as the axis.- User says "lenis", "smooth scroll", "apple-style smooth scrolling", "physics-based scroll", or
/lenis-smooth-scroll.
- User says "lenis", "smooth scroll", "apple-style smooth scrolling", "physics-based scroll", or
- Non-goals: not pinning/horizontally scrolling a section (→
gsap-scrolltrigger-pin), not zero-dependency individual scroll effects (→scroll-effects), not rewriting the author's original code.
Mental model
bản gốc (Rheinmir/uiux-asset) → copy nguyên file → chỉ sửa nội dung | if external libs are not allowed: assets/ (vanilla rút gọn) → ADAPT. Technical core: reduced-motion? → không Lenis / new Lenis → driver: 2A raf tự quản (không GSAP) XOR 2B gsap.ticker (có GSAP) → lenis.on('scroll', ScrollTrigger.update) → reveal/data-speed ăn theo cùng driver.
Input and output contract
| Field | Required? | Meaning | |
|---|---|---|---|
| In | target page/project | yes | has a build step (npm) or a static page (CDN) |
| In | does it use GSAP ScrollTrigger? | yes | decides driver 2A or 2B |
| In | are external libs allowed? | yes | decides the original version or assets/ |
| Out | Lenis scroll page | yes | a single driver; * 1000 if via gsap.ticker; lagSmoothing(0); the reduced-motion branch remains intact |
| Out | verify evidence | yes | playwright-verify: fast scrolling does not jump position + reduced-motion branch passes page.emulateMedia |
Rules and capabilities
- RULE-01 (MUST): Install:
npm i lenis gsap(recommended for projects with a build step) — or CDN as inassets/demo.html(unpkg.com/lenis,cdnjs.cloudflare.com/.../gsap) for static/quick-demo pages. Do not use the old Lenis version with package name@studio-freight/lenis(renamed tolenis) unless the project is pinning the old version for a reason. - RULE-02 (MUST): Common mistake #1: forgetting
gsap.ticker.lagSmoothing(0)→ scrolling is normally smooth but jerks once when the user switches tabs and comes back — because GSAP's automatic lag-smoothing stacks on top of Lenis's own smoothing mechanism (see the explanation in the Mechanism section). - RULE-03 (MUST): Common mistake #2: running BOTH 2A (
requestAnimationFrame(raf)self-managed) AND 2B (gsap.ticker.add) at the same time → two raf loops out of phase, causing continuous slight jitter. Choose only ONE driver: if GSAP is available use 2B, otherwise use 2A. - RULE-04 (MUST): Common mistake #3: calling
lenis.raf(time)fromgsap.tickerbut forgetting to multiply by* 1000(seconds → milliseconds) → easing is computed incorrectly. - RULE-05 (MUST):
prefers-reduced-motion: reduce→ do not initialize Lenis, do not fake smoothness with CSSscroll-behavior: smoothas a replacement — leave native browser scrolling completely intact (already coded inassets/lenis-demo.js, do not remove this branch when adapting). - RULE-06 (MUST): Verify with
playwright-verify: scroll rapidly and continuously, check that there is no position jump betweendata-ls-revealand the section containing it; also test the reduced-motion branch (simulated viapage.emulateMedia). - Capabilities: read/copy the sample file from an external repo or
assets/; write into the target project; install frontend dependencies (lenis, gsap); run a headless browser to verify.
Failure boundaries
- Cannot open/clone
Rheinmir/uiux-asset→ use theassets/reduced vanilla (partial), notify the user. - User has
prefers-reduced-motion: reduceenabled → do not initialize Lenis (correct behavior, not a bug). - Verification finds position jumps/jitter → failed, re-check the 3 common mistakes (#1–#3) before reporting completion.
- Project pins
@studio-freight/lenisfor a reason → keep it as is, clarify with the user before changing the package.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | judgment | request | Choose the appropriate original pen from the "Original" table, open the link run to see the correct effect | chosen pen | must not pull in external libs → B01 |
| W02 | effect | pen | Copy the original file .html (+ _assets/) into the project, keep the attribution comment, only edit content/images/colors; run over HTTP |
working page | — |
| W03 | deterministic | page | Install (npm i lenis gsap or CDN), initialize Lenis after the reduced-motion guard; pick ONE driver (2A or 2B) |
Lenis runs | with GSAP → B02 |
| W04 | deterministic | page | Verify with playwright-verify (fast scroll + reduced-motion via page.emulateMedia) |
pass | jerk/jump → check errors #1–#3, repeat W03 |
Step-by-step details (source of truth for W01–W04):
Original — use FIRST (copy the file as-is, don't rewrite)
The author's VERBATIM original code (public CodePen = MIT) is in the external repo Rheinmir/uiux-asset — live gallery: https://rheinmir.github.io/uiux-asset/scroll-effects/ .
Process: open link runs to see the exact effect → copy the file as-is .html (+ folder _assets/ if the file references it) into the project → keep the attribution comment on the first line → only change content/images/colors. Run locally over HTTP, not file://.
git clone --depth 1 https://github.com/Rheinmir/uiux-asset if you need the full offline set.
| Original Pen — author | Link |
|---|---|
| Lenis Smooth Scroll & GSAP Page — filipz | runs · code |
| Lenis Smooth Scroll Cinematic Experience — filipz | runs · code |
| Layout Explorations with GSAP, Flip, Lenis and ScrollTrigger N°2 — filipz | runs · code |
| Staggered Text Scroll Reveal — gusevdigital | runs · code |
assets/ in this skill is only a self-written vanilla condensed version — use when the project CANNOT pull external libs; by default use the original above.
Assets
assets/demo.html + assets/lenis-demo.css + assets/lenis-demo.js — a working demo page (Lenis CDN + GSAP), read then ADAPT (change tokens/sections per project, switch CDN to npm bundle if the project already has a build step).
Mechanism
1. Initialize Lenis
const lenis = new Lenis({
lerp: 0.1, // nội suy mỗi frame (0–1): thấp = mượt/trễ hơn, cao = bám sát input hơn
duration: 1.2, // easing khi có target (vd anchor scroll), không áp dụng cho wheel liên tục
smoothWheel: true, // bật vật lý mượt cho wheel/trackpad — lý do chính để dùng Lenis
});
2A. Standalone (no GSAP) — self-managed raf loop
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
2B. With GSAP ScrollTrigger — MERGE into a single driver (do not use both 2A and 2B at the same time)
gsap.registerPlugin(ScrollTrigger);
lenis.on('scroll', ScrollTrigger.update); // Lenis cuộn -> báo ScrollTrigger cập nhật ngay
gsap.ticker.add((time) => lenis.raf(time * 1000)); // gsap.ticker LÀM vòng raf cho Lenis luôn
gsap.ticker.lagSmoothing(0); // tắt bù-lag mặc định của GSAP (xem lý do bên dưới)
Why these 3 lines must be in exactly this order (the most common mistake):
gsap.tickeralready runs its ownrequestAnimationFrameloop internally to drive all tweens/ScrollTriggers. If Lenis runs its own extrarequestAnimationFrame(raf)(approach 2A) WHILE GSAP is already present, the two loops are out of sync — each frame they update a few milliseconds apart, and ScrollTrigger reads a scroll position "older" or "newer" than Lenis by one frame. The feel is a constant slight jerk when scrolling fast, especially in sections withpinorscrub. Fix it by letting gsap.ticker be the ONLY driver: remove the separaterequestAnimationFrame(raf), replace it withgsap.ticker.add(...).gsap.tickeremittimein seconds, whilelenis.raf()needs milliseconds → must multiply by* 1000. Forgetting to multiply is a silent error: Lenis runs but easing/velocity are computed wrong on a time scale 1000 times smaller, scroll looks "stuck" or "flying" depending on lerp.lenis.on('scroll', ScrollTrigger.update)is mandatory because Lenis updates scroll position via internal transform (lerpeach frame, notscrollTopinstantly) — if not reported, ScrollTrigger still uses the browser's nativescrollevent to compute pin/progress, which does NOT match the "smooth" position Lenis is rendering → pin drifts by a few px or triggers fire at the wrong time.gsap.ticker.lagSmoothing(0)handles a different bug, easily missed: by default GSAP auto-detects abnormally delayed frames (e.g., a tab loses focus for a few seconds then returns) and "compresses" time to compensate, so animations don't jump. But Lenis already has its own delta-time smoothing mechanism — two time-compensation systems stacked cause the tab-return to jitter noticeably instead of being smooth. Disable GSAP's compensation entirely (lagSmoothing(0)) so Lenis alone handles that part.
3. Respect prefers-reduced-motion: reduce
Do not "fake-smooth" for users who have reduced motion enabled — do not initialize Lenis at all, let the browser use native scroll (normal jump-scroll). Check BEFORE calling new Lenis(...):
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// new Lenis(...) + sync ở trên
} else {
// cuộn gốc trình duyệt; nếu có reveal-on-scroll đi kèm, hiện thẳng bằng CSS/class, không chờ cuộn mượt
}
See the full application (with reveal fallback demo) in assets/lenis-demo.js.
4. Demo: reveal + data-speed when scrolling via Lenis
assets/demo.html has a few sections with data-ls-reveal (staggered fade-in, using ScrollTrigger.create({ once: true }) tied to scroll already synced in step 2B) and data-speed="0.12" (layer drifting slightly via gsap.to(..., { scrollTrigger: { scrub: true } }), value clamped |v| ≤ 0.3 to avoid motion sickness) — both use the SAME established Lenis/GSAP driver, no separate scroll listener.
Branches
| ID | Kind | Guard | Behavior | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | project must NOT pull external lib / needs a self-written version | use assets/demo.html + assets/lenis-demo.css + assets/lenis-demo.js, ADAPT token/section |
— | W03 |
| B02 | conditional_required | page has GSAP ScrollTrigger | driver 2B: lenis.on('scroll', ScrollTrigger.update) + gsap.ticker.add((time) => lenis.raf(time * 1000)) + gsap.ticker.lagSmoothing(0); drop 2A entirely |
no GSAP → 2A | W04 |
| B03 | conditional_required | prefers-reduced-motion: reduce |
no new Lenis, native scroll; reveal shows directly via CSS/class |
— | W04 |
| B04 | user_optional | page needs additional pin/horizontal-scroll section | initialize Lenis per this skill FIRST then use gsap-scrolltrigger-pin above |
— | W04 |
Validation and stopping
Done when playwright-verify scrolls rapidly and repeatedly without position jumps between data-ls-reveal and the section containing it, and the reduced-motion branch (via page.emulateMedia) the original scroll does not use Lenis. The code has only ONE raf driver.
Examples
- Positive: "make an apple-style smooth-scrolling landing page with parallax" → choose the pen "Lenis Smooth Scroll & GSAP Page — filipz", copy it as is
lenis-smooth-scroll-gsap-page.html, change content/images → verify fast scrolling does not misalign the pin, reduced-motion uses the original scroll. - Boundary/failure: the adapted code has both
requestAnimationFrame(raf)andgsap.ticker.add(...)→ slight continuous jitter (bug #2) → remove loop 2A, keep only 2B, verify again; forget* 1000then scrolling "freezes/drifts" (bug #3).
Origin
Batch 180926 — the original VERBATIM version is at Rheinmir/uiux-asset/scroll-effects/ (see the "Original" section above); assets/ is a trimmed-down vanilla version written by hand.