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.
72
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
scrub.scrub: true or scrub: 1.Avoid:
npm i gsap lenisimport 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.<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.
function 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.
function 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,
},
}
);
});
}.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.
function 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.
function 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.
[data-sticky-stack] {
position: relative;
}
[data-stack-card] {
position: sticky;
top: 12vh;
transform-origin: center top;
will-change: transform, opacity;
}function 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.
function 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:
function 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,
},
}
);
}Create 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.46abf78
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.