Cosmic Scroll Story — Pinned Section with Clip-Path Reveals, Deep Zoom & Flip-Open Headline
Goal
Build a long-scroll "scrollytelling" page whose centerpiece is a single section pinned for 4 viewport heights while every stage is scrubbed by scroll. As you scroll through the pinned section: the letters of two intro paragraphs flicker in one-by-one in a random order; a small centered rectangle expands via an animated clip-path into a fullscreen image (its photo scaling up at the same time); then a third image opens outward from a central seam while it is held at a heavy 3× zoom, and finally zooms back out to natural scale; and as a closing beat a headline that starts folded away at rotateY(-75deg) swings flat to camera to reveal the last line of copy. All of it is driven by GSAP ScrollTrigger (one pin + many scrubbed tweens) with Lenis smooth scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll. Register with gsap.registerPlugin(ScrollTrigger). Run everything inside a DOMContentLoaded handler. No SplitText, no CustomEase, no Three.js — the letter split is done by hand.
Layout / HTML
Single .container wrapping five full-viewport sections in order. Class names are load-bearing (JS/CSS query them):
<div class="container">
<section class="hero">
<h1>Genesis</h1>
<p>At the nexus of infinite realities, the Aethoria Spire rises: a beacon of limitless potential in the cosmic tapestry.</p>
</section>
<section class="about">
<div class="about-img"><img src="..." alt="" /></div>
<div class="about-copy"><h1>The Omniversal Fulcrum</h1></div>
</section>
<section class="sticky">
<div class="intro">
<div class="intro-col">
<p>Veiled depths of eternity</p>
<p>Currents of cosmic wisdom</p>
</div>
<div class="intro-col">
<p>Nexus of all existence</p>
</div>
</div>
<div class="img-1"><img src="..." alt="" /></div>
<div class="img-2"><img src="..." alt="" /></div>
<div class="img-3"><img src="..." alt="" /></div>
<div class="copy">
<h1>The cosmic keystone within Aethoria unlocks the gates to multiversal transformation</h1>
</div>
</section>
<section class="footer">
<h1>Infinite Realms <br /> Beckon Beyond</h1>
</section>
</div>
All copy is fictional cosmic flavor text — keep it neutral, no brand names.
Styling
Fonts: body/paragraphs use Roboto Mono (Google Fonts, weights 100–700). Every h1 uses a heavy condensed uppercase display face — declare font-family: "FK Screamer" with a robust fallback (e.g. a bold grotesque like "Anton", "Archivo Black", sans-serif); it must render as a massive, tightly-set, all-caps headline.
Global reset & base:
* { margin:0; padding:0; box-sizing:border-box }.html, body { width:100%; height:700vh !important; font-family:"Roboto Mono", monospace }— the 700vh body height is essential: it is the total scroll runway that makes the pin + all scrubbed ranges reachable.img { width:100%; height:100%; object-fit:cover }.h1 { text-transform:uppercase; font-size:15vw; font-weight:600; line-height:90%; letter-spacing:2px; color:#fff }.p { text-transform:uppercase; font-size:14px; font-weight:400; color:#fff }.section { width:100vw; height:100vh }.
Sections:
.hero:padding:4em; display:flex; flex-direction:column; justify-content:space-between; align-items:center; full-bleed background imagebackground: url(...) no-repeat 50% 50%; background-size:cover..hero p { width:50%; text-align:center }..about:padding:4em 12em; display:flex; justify-content:center; align-items:center; gap:10em; background-color:#667e74(muted sage green)..about-img, .about-copy { flex:1 }..about-img { height:75%; border:2px solid #000 }..about-copy h1 { text-align:center; font-size:10vw; color:#263a30 }(dark green, overrides the white)..footer:padding:2em; display:flex; justify-content:center; align-items:center; text-align:center; background-color:#1d2944(dark navy)..sticky:position:relative; perspective:1000px— theperspective:1000pxis required for the closing headline's 3DrotateYswing to read as depth.
Sticky-section children (all layered inside .sticky):
.intro { position:absolute; top:50%; transform:translateY(-50%); width:100%; padding:1em; display:flex; z-index:2 }— sits above the images..intro-col { flex:1; display:flex },.intro-col p { flex:1 },.intro-col p span { display:inline-block }. Second column right-aligned:.intro-col:nth-child(2) p { text-align:right }..img-1, .img-2, .img-3 { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden }— three stacked full-cover layers (img-1 bottom, img-3 top)..img-2initialclip-path: polygon(40% 25%, 60% 25%, 60% 75%, 40% 75%)— a small centered rectangle (20% wide × 50% tall)..img-3initialclip-path: polygon(50% 25%, 50% 25%, 50% 75%, 50% 75%)— a zero-width vertical seam down the center..img-3 img { transform-origin:top right; transform:scale(3) }— the top image starts zoomed to 3×, anchored to its top-right..copy { width:50%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) rotateY(-75deg) scale(0.5); transform-origin:bottom left; display:none }— the closing headline starts hidden, folded 75° away and half-scale..copy h1 { font-size:5vw; text-align:center }.
Responsive (max-width:900px): h1 → font-size:20vw; .hero p → width:100%; .about stacks column (flex-direction:column; gap:4em; padding:4em 2em), .about-copy { flex:0.5 }, .about-img { margin-top:4em; height:100% }; .intro and .intro-col become flex-direction:column, .intro-col p { text-align:center !important }; .copy { width:90% }, .copy h1 { font-size:10vw }.
GSAP effect (be exact)
Lenis + ScrollTrigger wiring
const stickySection = document.querySelector(".sticky");
const totalStickyHeight = window.innerHeight * 4;
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Lenis drives scroll; GSAP's ticker drives Lenis (seconds → ms via * 1000). totalStickyHeight (4 × viewport) is the pin length and the reference for every absolute scroll marker below.
1. Manual letter split (no SplitText)
For each .intro-col p, take its textContent, split on whitespace (keep the spaces), and for every non-space chunk wrap each character in <span style="opacity:0; display:inline-block;">…</span>. Reassemble as innerHTML. Result: every letter is an individually-addressable, initially-invisible inline-block span; spaces stay as plain text.
2. Flicker-in / flicker-out of the intro letters
A reusable helper animates all letter spans to a target opacity with a random-order flicker:
function flickerAnimation(targets, toOpacity) {
gsap.to(targets, {
opacity: toOpacity,
duration: 0.05,
stagger: { amount: 0.3, from: "random" },
});
}
duration:0.05per letter (near-instant snap),stagger.amount:0.3spreads the whole batch across 0.3s total,from:"random"scatters the order → a flickering, non-linear appearance.
Fire it from a callback-only ScrollTrigger (not scrubbed):
ScrollTrigger.create({
trigger: stickySection,
start: "top top",
end: () => `${window.innerHeight * 3}`, // absolute scroll position (px), NOT "+="
onEnter: () => flickerAnimation(".intro-col p span", 1),
onLeave: () => flickerAnimation(".intro-col p span", 0),
onEnterBack: () => flickerAnimation(".intro-col p span", 1),
onLeaveBack: () => flickerAnimation(".intro-col p span", 0),
});
So the letters flicker on when the pin begins and flicker off near the end of the third viewport of scroll; scrolling back reverses it. (Because end is a bare number string it is treated as an absolute page-scroll position of 3 × innerHeightpx, not a relative offset.)
3. Pin the sticky section
ScrollTrigger.create({
trigger: stickySection,
start: "top top",
end: () => `+=${totalStickyHeight}`, // +=4×innerHeight
pin: true,
pinSpacing: true,
});
The section locks to the viewport for 4 viewport-heights of scrolling; pinSpacing:true inserts that runway so the footer follows afterward.
4. img-2 reveal — clip-path expand + photo scale (first viewport, scroll 0 → 1×vh)
Two scrubbed tweens over start:"top top", end:() => "+="+window.innerHeight, scrub:true, ease:"none":
(a) clip-path driven manually in onUpdate by interpolating the small centered rectangle out to the full frame:
scrollTrigger: {
trigger: stickySection, start: "top top",
end: () => `+=${window.innerHeight}`, scrub: true,
onUpdate: (self) => {
const p = self.progress;
gsap.set(".img-2", { clipPath: `polygon(
${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(25, 0, p)}%,
${gsap.utils.interpolate(60, 100, p)}% ${gsap.utils.interpolate(75, 100, p)}%,
${gsap.utils.interpolate(40, 0, p)}% ${gsap.utils.interpolate(75, 100, p)}%
)` });
},
}
Corner journeys: left x 40→0, right x 60→100, top y 25→0, bottom y 75→100. The centered rectangle grows outward on all four edges into polygon(0 0,100 0,100 100,0 100) — a rectangular iris opening to fullscreen. (The tween's own clipPath target is that full polygon; the onUpdate overrides each frame so the interpolation is explicit.)
(b) .img-2 img scale → 1.125 over the same range (a slow inward push while the frame opens).
5. img-3 open + hold-zoom (fourth viewport, scroll 3×vh → 4×vh)
These use absolute scroll markers start:() => window.innerHeight*3, end:() => window.innerHeight*4, scrub:true, ease:"none".
(a) clip-path opens from the center outward, again interpolated in onUpdate:
onUpdate: (self) => {
const p = self.progress;
gsap.set(".img-3", { clipPath: `polygon(
${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 0, p)}%,
${gsap.utils.interpolate(50, 100, p)}% ${gsap.utils.interpolate(50, 100, p)}%,
${gsap.utils.interpolate(50, 0, p)}% ${gsap.utils.interpolate(50, 100, p)}%
)` });
}
All four corners start at the center point 50% 50% and expand to the frame corners → the top image irises open from the middle to fullscreen. (Target clipPath is the full polygon.)
(b) .img-2 img continues scaling fromTo(1.125 → 1.25) over this same 3×→4× range (keeps the middle layer creeping in).
(c) .img-3 img scale to: 2.9 over 3×→4× — it is essentially held at its heavy 3× zoom (CSS start scale is 3, animating to 2.9), so as img-3 opens you see a deep, tightly-cropped detail.
6. img-3 zoom-out (scroll 4×vh → 6×vh)
gsap.fromTo(".img-3 img", { scale: 2.9 }, {
scale: 1, ease: "none",
scrollTrigger: {
trigger: stickySection,
start: () => `${window.innerHeight * 4}`,
end: () => `${window.innerHeight * 6}`,
scrub: true,
},
});
Over the next two viewports the top image pulls back from 2.9× to its natural 1× — the "reveal the whole picture" beat. (This range extends past the 4×vh pin release, so the zoom-out finishes as the section scrolls away.)
7. Closing headline flip-open (scroll 4.5×vh → 5.5×vh)
const tl = gsap.timeline({
scrollTrigger: {
trigger: stickySection,
start: () => `${window.innerHeight * 4.5}`,
end: () => `${window.innerHeight * 5.5}`,
scrub: true,
toggleActions: "play reverse play reverse",
},
});
tl.to(".copy", { display: "block", rotateY: 0, scale: 1, duration: 1 });
.copy (hidden, rotateY(-75deg) scale(0.5), transform-origin:bottom left) swings flat to rotateY(0) scale(1) and becomes visible — with the parent perspective:1000px, it reads as a panel hinging open toward the viewer to present the final line. Scrubbed and reversible.
Timing summary (in units of window.innerHeight of scroll)
0 → 1: intro letters flickered in (from pin start); img-2 clip-path iris opens to fullscreen; img-2 photo scales 1→1.125.1 → 3: pinned, mostly holding (letters still on).~3: intro letters flicker out (flicker trigger ends at3).3 → 4: img-3 clip-path irises open from center; img-3 photo held ~3× (3→2.9); img-2 photo continues 1.125→1.25.4 → 6: img-3 photo zooms out 2.9 → 1 (pin releases at 4).4.5 → 5.5: closing.copyheadline flips fromrotateY(-75deg) scale(0.5)to flat.
All scrubbed tweens use ease:"none" so motion is linearly tied to scroll; the only non-scrubbed animation is the flicker (its own short easing/stagger).
Assets / images
Five images, all photographic and atmospheric — a matched cinematic/cosmic set (nebulae, celestial landscapes, monolithic sci-fi structures, deep-space vistas), no logos or text:
- 1 hero background — full-bleed landscape (≈16:9), the opening cosmic backdrop behind the "Genesis" title.
- 1 about image — portrait-ish framed photo (roughly 3:4), sits inside a 2px black border beside the green section's heading.
- 3 fullscreen sticky images — each fills a 100vw × 100vh layer with
object-fit:cover, so use large landscape-or-square images (≈16:9). img-1 is the bottom backdrop; img-2 is the rectangle that irises open; img-3 is the top layer that opens from the seam and holds a deep zoom, so it benefits from an image with strong central detail that rewards the 3× crop.
Behavior notes
- The page hijacks scroll via Lenis and pins a full section for 4 viewport-heights; it is a page-level, immersive experience (desktop-first but the
max-width:900pxquery reflows it for mobile). - Everything except the letter flicker is fully scrubbed and reversible — scrolling back plays every reveal, zoom and flip backwards.
- The 700vh body height, the pin length (
4×innerHeight), and the absolute scroll markers (3×,4×,4.5×,5.5×,6×innerHeight) must line up — they're what sequence the beats; recompute them fromwindow.innerHeight(the demo reads it at load). - Light performance cost (no canvas/WebGL); the clip-path morphs are the heaviest part.