| name | cinematic-scroll-storytelling |
| description | 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. |
Cinematic Scroll Storytelling
Use When
- A page should feel like a premium editorial story that unfolds as the user scrolls.
- The user mentions scroll-driven storytelling, scroll-linked animation, sticky card stacks, parallax, split text, preloader, or cinematic progression.
- A portfolio, studio, product, or landing page needs section-by-section reveals with layered depth.
- The implementation can use GSAP, ScrollTrigger, and Lenis.
Effect Vocabulary
- Scroll-driven storytelling: sections reveal as a sequence while scrolling.
- Scroll-linked animation: progress is tied directly to scroll with
scrub.
- Scroll-triggered motion: animation starts when a section enters the viewport.
- Staggered reveal: words, lines, cards, or elements enter with small delays.
- Progressive reveal: opacity, scale, blur, clip, or position changes over scroll progress.
- Sticky card stack: sticky cards layer, scale, and recede as the next card arrives.
- Parallax scrolling: background and foreground layers move at different speeds.
- Scroll scrubbing: animation follows the scrollbar through
scrub: true or scrub: 1.
- Kinetic typography: masked split-text movement, usually word-by-word or line-by-line.
- Preloader: opening loading screen, progress bar, and intro transition.
Target Feel
- Luxury editorial website.
- High-end creative studio portfolio.
- Apple-level motion polish.
- Modern Awwwards interaction language.
- Immersive cinematic landing page.
Avoid:
- Bounce, elastic, or springy motion.
- Aggressive scale jumps.
- Flashy gaming-style effects.
- Too many simultaneous scroll effects.
- Scroll hijacking that makes the page hard to read.
Core Stack
npm i gsap lenis
import 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());
Motion Tokens
- Enter ease:
power3.out or power4.out.
- Scrubbed scenes:
ease: "none" with scrub: 0.8 to 1.4.
- Text reveal duration:
0.8s to 1.1s.
- Card reveal duration:
0.9s to 1.2s.
- Word stagger:
0.035s to 0.07s.
- Line stagger:
0.08s to 0.14s.
- Card stagger:
0.06s to 0.1s.
- Reveal offset:
y: 24 to 48.
- Blur:
4px to 10px, then 0px.
- Sticky card scale depth:
1 down to 0.92.
Page Anatomy
- Preloader: black screen, progress bar, brand/title, intro fade.
- Hero: image parallax, masked headline reveal, subtle scroll cue.
- Intro: word-by-word kinetic typography.
- Story sections: scroll-triggered fade-up, blur-in, and clip reveals.
- Recent Projects: sticky card stack with scale and layered depth.
- Gallery or proof: scroll-scrubbed horizontal or progressive reveals.
- Footer: parallax reveal or slow upward handoff.
Markup Pattern
<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>
Preloader Sequence
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");
});
}
Split Text Reveal
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;
}
Scroll-Triggered Reveals
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,
},
}
);
});
}
Scroll-Linked Progression
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);
});
}
Sticky Card Stack
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,
},
});
});
});
}
Parallax
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,
},
}
);
}
Build Order
- Build the static page first.
- Add preloader and hero entrance.
- Add split text reveals.
- Add section-by-section reveals.
- Add sticky card stack progression.
- Add parallax layers.
- Add scrubbed pinned scenes only where the story needs them.
- Add reduced-motion and touch fallbacks.
- Run browser QA across desktop and mobile.
Prompt Template
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.
QA Checklist
- Content is readable with JavaScript disabled.
- Reduced-motion users see static content and no smooth-scroll layer.
- Scroll-triggered reveals play once.
- Scroll-linked scenes use
scrub intentionally.
- Sticky cards do not overlap the footer or trap the page.
- Parallax movement stays subtle and does not harm readability.
- Preloader exits reliably even if images load slowly.
ScrollTrigger.refresh() runs after images/fonts/layout shifts.
- Mobile has simplified pinning or no pinning if performance drops.