Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Create cinematic scroll-driven landing pages with Lenis smooth scrolling, GSAP ScrollTrigger, scroll-linked progression, staggered text reveals, sticky card stacks, parallax backgrounds, scroll-scrubbed transitions, footer reveals, and immersive preloaders. Use when analyzing or building premium editorial scroll experiences, sticky project stacks, kinetic typography, or section-by-section storytelling.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 116% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 138% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 80% | 0% |
| case-21 | ✗→✓ | ▲ Improved | 112% | 0% |
scrub.scrub: true or scrub: 1.Avoid:
bashnpm i gsap lenis
jsimport Lenis from "lenis"; import "lenis/dist/lenis.css"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!reduceMotion) { const lenis = new Lenis({ lerp: 0.08, smoothWheel: true, wheelMultiplier: 0.9, }); lenis.on("scroll", ScrollTrigger.update); gsap.ticker.add((time) => { lenis.raf(time * 1000); }); gsap.ticker.lagSmoothing(0); } window.addEventListener("load", () => ScrollTrigger.refresh());
power3.out or power4.out.ease: "none" with scrub: 0.8 to 1.4.0.8s to 1.1s.0.9s to 1.2s.0.035s to 0.07s.0.08s to 0.14s.0.06s to 0.1s.y: 24 to 48.4px to 10px, then 0px.1 down to 0.92.html<div class="preloader" data-preloader> <div class="preloader__bar" data-preloader-bar></div> </div> <main> <section class="hero" data-parallax-section> <img data-parallax-layer data-speed="-0.18" src="/hero.jpg" alt=""> <h1 data-split-reveal>Design that unfolds with cinematic restraint.</h1> </section> <section data-story-section> <p data-split-reveal="words">Every block arrives with quiet intent.</p> </section> <section class="project-stack" data-sticky-stack> <article data-stack-card>Project One</article> <article data-stack-card>Project Two</article> <article data-stack-card>Project Three</article> </section> <footer data-footer-parallax>...</footer> </main>
Use a preloader to set the cinematic tone, then hand off into the hero reveal.
jsfunction initPreloader() { const loader = document.querySelector("[data-preloader]"); const bar = document.querySelector("[data-preloader-bar]"); if (!loader) return Promise.resolve(); if (reduceMotion) { loader.remove(); return Promise.resolve(); } return new Promise((resolve) => { const tl = gsap.timeline({ defaults: { ease: "power3.out" }, onComplete: () => { loader.remove(); resolve(); }, }); tl.fromTo(bar, { scaleX: 0, transformOrigin: "left" }, { scaleX: 1, duration: 1.1 }) .to(loader, { yPercent: -100, duration: 0.9, ease: "power4.inOut" }, "+=0.15"); }); }
Use masked overflow containers. Avoid splitting text that contains links or meaningful inline markup.
jsfunction splitWords(element) { if (element.dataset.splitReady === "true") return; const text = element.textContent || ""; const parts = text.split(/(\s+)/); element.textContent = ""; element.setAttribute("aria-label", text.trim()); parts.forEach((part) => { if (!part.trim()) { element.appendChild(document.createTextNode(part)); return; } const mask = document.createElement("span"); const word = document.createElement("span"); mask.className = "split-word-mask"; word.className = "split-word"; word.textContent = part; mask.setAttribute("aria-hidden", "true"); mask.appendChild(word); element.appendChild(mask); }); element.dataset.splitReady = "true"; } function initSplitReveals() { if (reduceMotion) { gsap.set("[data-split-reveal]", { autoAlpha: 1 }); return; } gsap.utils.toArray("[data-split-reveal]").forEach((element) => { splitWords(element); const words = element.querySelectorAll(".split-word"); gsap.fromTo( words, { yPercent: 110, autoAlpha: 0, filter: "blur(8px)" }, { yPercent: 0, autoAlpha: 1, filter: "blur(0px)", duration: 0.95, ease: "power4.out", stagger: 0.05, scrollTrigger: { trigger: element, start: "top 82%", once: true, }, } ); }); }
css.split-word-mask { display: inline-block; overflow: hidden; vertical-align: top; } .split-word { display: inline-block; will-change: transform, opacity, filter; }
Use these for normal sections. They should play once and feel composed, not twitchy.
jsfunction initSectionReveals() { if (reduceMotion) { gsap.set("[data-story-section], [data-reveal-item]", { autoAlpha: 1, clearProps: "all" }); return; } gsap.utils.toArray("[data-story-section]").forEach((section) => { const items = section.querySelectorAll("[data-reveal-item]"); const targets = items.length ? items : section.children; gsap.fromTo( targets, { y: 36, autoAlpha: 0, filter: "blur(8px)" }, { y: 0, autoAlpha: 1, filter: "blur(0px)", duration: 1, ease: "power4.out", stagger: 0.08, scrollTrigger: { trigger: section, start: "top 82%", once: true, }, } ); }); }
Use scrubbed timelines for cinematic progression. Keep scrubbed animation linear and let the scroll position do the timing.
jsfunction initProgressionScenes() { if (reduceMotion) return; gsap.utils.toArray("[data-progress-scene]").forEach((scene) => { const media = scene.querySelector("[data-progress-media]"); const copy = scene.querySelectorAll("[data-progress-copy]"); gsap.timeline({ scrollTrigger: { trigger: scene, start: "top top", end: "+=140%", scrub: 1.1, pin: true, anticipatePin: 1, }, }) .fromTo(media, { scale: 1.08 }, { scale: 1, ease: "none" }) .fromTo(copy, { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, stagger: 0.15, ease: "none" }, 0.15); }); }
Use position: sticky for layout, and ScrollTrigger for layered scale/depth. Earlier cards should recede as later cards arrive.
css[data-sticky-stack] { position: relative; } [data-stack-card] { position: sticky; top: 12vh; transform-origin: center top; will-change: transform, opacity; }
jsfunction initStickyCardStack() { if (reduceMotion) return; gsap.utils.toArray("[data-sticky-stack]").forEach((stack) => { const cards = gsap.utils.toArray(stack.querySelectorAll("[data-stack-card]")); cards.forEach((card, index) => { const nextCard = cards[index + 1]; if (!nextCard) return; gsap.to(card, { scale: 0.92 + index * 0.015, autoAlpha: 0.72, y: -24, ease: "none", scrollTrigger: { trigger: nextCard, start: "top 78%", end: "top 24%", scrub: true, invalidateOnRefresh: true, }, }); }); }); }
Use parallax for hero images, background layers, and footer reveals. Keep distance small.
jsfunction initParallax() { if (reduceMotion) return; gsap.utils.toArray("[data-parallax-layer]").forEach((layer) => { const speed = Number(layer.dataset.speed || -0.16); const section = layer.closest("[data-parallax-section]") || layer; gsap.to(layer, { y: () => window.innerHeight * speed, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: 1, invalidateOnRefresh: true, }, }); }); }
Footer parallax reveal:
jsfunction initFooterReveal() { if (reduceMotion) return; const footer = document.querySelector("[data-footer-parallax]"); if (!footer) return; gsap.fromTo( footer, { yPercent: -12, autoAlpha: 0.85 }, { yPercent: 0, autoAlpha: 1, ease: "none", scrollTrigger: { trigger: footer, start: "top bottom", end: "top 45%", scrub: 1, }, } ); }
txtCreate a cinematic scroll-driven landing page with smooth Lenis scrolling, GSAP ScrollTrigger animations, staggered text reveals, sticky card stack progression, parallax backgrounds, scroll-scrubbed transitions, section-by-section storytelling, and an immersive preloader animation. Use layered depth, scaling transitions, progressive opacity changes, and smooth viewport-triggered motion for a premium editorial experience.
scrub intentionally.ScrollTrigger.refresh() runs after images/fonts/layout shifts.Other measured skills in the registry, with their headline benchmark lift.