Epic Scroll Story — Rotating Clip-Path Cross that Scales Up to Wipe the Screen
Goal
Build a long, cinematic single-page scroll story. A tiny white plus/cross shape sits over a pinned dark editorial section; as you scroll, it rotates a full 360°, its clip-path expands from a thin cross into a solid white square, it drifts horizontally from left-of-center to center, and finally scales up ~13× to white-out the entire viewport and hand off to a final white content section. Everything is driven by several scrubbed / onUpdate GSAP ScrollTriggers over Lenis-smoothed scrolling, plus two pinned sections.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Run everything inside a DOMContentLoaded handler.
Layout / HTML
Class names are load-bearing — the JS and CSS query them. Stack these sections inside a single .container:
<div class="container">
<section class="hero">
<h1>Symphonia</h1>
</section>
<section class="info">
<div class="header-rows">
<div class="header-row"><h1>Motion</h1></div>
<div class="header-row"><h1>Stills</h1></div>
</div>
</section>
<section class="header-info">
<p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Labore praesentium eaque ipsa illum rem cumque iusto natus, saepe provident quasi?</p>
<div class="header-images">
<div class="img"><img src="hero-images/img-1.jpg" alt="" /></div>
<div class="img"><img src="hero-images/img-2.jpg" alt="" /></div>
<div class="img"><img src="hero-images/img-3.jpg" alt="" /></div>
<div class="img"><img src="hero-images/img-4.jpg" alt="" /></div>
</div>
</section>
<section class="whitespace"></section>
<section class="pinned">
<div class="revealer">
<div class="revealer-1"></div>
<div class="revealer-2"></div>
</div>
</section>
<section class="website-content">
<h1>Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia nobis qui corrupti sit, ad facilis, natus magnam culpa facere sunt pariatur? Voluptatum qui quis sit dolore, dolorum est neque animi!</h1>
</section>
</div>
- Hero title: "Symphonia". Info headers: "Motion" (top row) and "Stills" (bottom row). Paragraph and final content use placeholder lorem ipsum as above.
- The DOM order matters:
.whitespacecomes before.pinnedin the markup even though.pinnedis absolutely positioned to overlay earlier sections (see Styling).
Styling
Fonts: Body is "PP Neue Montreal" (a clean neutral grotesque sans — any similar grotesque works as fallback). The hero title and the header-info paragraph use "PP Editorial Old" (an elegant high-contrast editorial serif — fall back to a display serif). No @font-face is required; naming the families with sane fallbacks is fine.
Global:
* { margin:0; padding:0; box-sizing:border-box; }html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; overflow-x:hidden; }img { width:100%; height:100%; object-fit:cover; }h1 { text-transform:uppercase; font-size:200px; font-weight:400; letter-spacing:-4px; }— yes, 200px display headings.
Sections (all full-bleed width):
section.hero { width:100vw; height:100vh; background:url(hero-images/hero.jpg) no-repeat 50% 50%; background-size:cover; display:flex; justify-content:center; align-items:center; }—.hero h1 { font-family:"PP Editorial Old"; font-weight:lighter; color:#fff; }(white title over the dark photo).section.info { width:100vw; height:150vh; background:#000; color:#fff; }. Inside,.header-row { width:100%; height:250px; padding:0 2em; display:flex; align-items:center; }; the 1st row isjustify-content:flex-start(Motion pinned left), the 2nd isjustify-content:flex-end(Stills pinned right)..header-info { position:relative; width:100%; height:100vh; display:flex; flex-direction:column; justify-content:space-between; background:#000; color:#fff; }..header-info p { padding:1em; font-family:"PP Editorial Old"; font-size:52px; font-weight:lighter; }(paragraph sits at the top)..header-images { width:100%; height:250px; padding:1em; display:flex; gap:1em; }(four thumbnails in a row at the bottom);.img { width:100%; height:100%; }.section.whitespace { position:relative; width:100%; height:300vh; background:#000; z-index:-1; }— a tall black spacer that provides scroll runway; sits behind everything (z-index:-1).section.pinned { position:absolute; top:100vh; width:100%; height:250vh; z-index:2; }— absolutely positioned so it overlays starting exactly one viewport down (right after the hero), floating above the black sections.section.website-content { position:relative; width:100%; height:150vh; background:#fff; z-index:10; }— white panel on top of everything..website-content h1 { font-size:72px; letter-spacing:0; }(dark default text on white).
The revealer (the star element) — a tiny cross built from two clipped white boxes:
.revealer { position:absolute; transform:translate(-50%,0%); left:35%; margin-top:325px; width:120px; height:120px; }— a 120×120 box, positioned left-of-center (35%) and pushed down 325px inside.pinned..revealer .revealer-1 { position:absolute; inset:0; width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); }— a thin vertical white bar (only the center 45–55% column shows)..revealer .revealer-2 { position:absolute; inset:0; transform:rotate(90deg); width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); }— same clip but rotated 90°, so it reads as a thin horizontal bar. The two bars together form a small white plus (+) / cross.
Lenis boilerplate CSS (recommended): html.lenis, html.lenis body { height:auto; } .lenis.lenis-smooth { scroll-behavior:auto !important; } .lenis.lenis-stopped { overflow:hidden; }
Responsive (@media (max-width:900px)): .hero h1 { font-size:42px; letter-spacing:0; }; .header-row { height:100px; } and .header-row h1 { font-size:60px; letter-spacing:0; }; .header-info p { font-size:24px; }; .website-content h1 { font-size:48px; padding:1em; }; and re-center the revealer: .revealer { left:50% !important; width:100px; height:100px; margin-top:400px; }.
GSAP effect (be exact)
Lenis ↔ ScrollTrigger wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Lenis drives scrolling; GSAP's ticker drives Lenis (seconds → ms, hence * 1000); every Lenis scroll calls ScrollTrigger.update.
Then create six ScrollTriggers, in this order:
1. Pin .pinned.
ScrollTrigger.create({
trigger: ".pinned",
start: "top top",
endTrigger: ".whitespace",
end: "bottom top",
pin: true,
pinSpacing: false,
});
The .pinned section (with the revealer) sticks to the top of the viewport from the moment its top hits the top, until the bottom of .whitespace reaches the top of the viewport. pinSpacing:false so it doesn't add layout space.
2. Pin .header-info.
ScrollTrigger.create({
trigger: ".header-info",
start: "top top",
endTrigger: ".whitespace",
end: "bottom top",
pin: true,
pinSpacing: false,
});
Same end point as the first pin, so the dark editorial block (paragraph + 4 thumbnails) stays frozen behind the revealer across the entire whitespace runway. pinSpacing:false.
3. Rotate the revealer 0° → 360°.
ScrollTrigger.create({
trigger: ".pinned",
start: "top top",
endTrigger: ".header-info",
end: "bottom bottom",
onUpdate: (self) => {
const rotation = self.progress * 360;
gsap.to(".revealer", { rotation });
},
});
No scrub. On every scroll update it fires a gsap.to(".revealer", { rotation }) with GSAP defaults (duration ≈ 0.5s, ease power1.out) — this gives the rotation a slight trailing/catch-up smoothing (unlike the instant tweens below). Progress is measured from .pinned top-top to .header-info bottom-bottom.
4. Expand the cross into a solid square via clip-path.
ScrollTrigger.create({
trigger: ".pinned",
start: "top top",
endTrigger: ".header-info",
end: "bottom bottom",
onUpdate: (self) => {
const p = self.progress;
const clipPath = `polygon(
${45 - 45 * p}% ${0}%,
${55 + 45 * p}% ${0}%,
${55 + 45 * p}% ${100}%,
${45 - 45 * p}% ${100}%
)`;
gsap.to(".revealer-1, .revealer-2", { clipPath, ease: "none", duration: 0 });
},
});
Same trigger window as #3. As progress 0 → 1, each bar's clip left edge goes 45% → 0% and right edge 55% → 100%, so both bars widen from thin strips into the full 120×120 box. Because one bar is rotated 90°, the pair morphs from a small plus into a filled white square. duration:0, ease:"none" → the clip is bound instantly to scroll (no smoothing lag).
5. Drift the revealer horizontally left:35% → 50% (toward center).
ScrollTrigger.create({
trigger: ".header-info",
start: "top top",
end: "bottom 50%",
scrub: 1,
onUpdate: (self) => {
const left = 35 + (50 - 35) * self.progress;
gsap.to(".revealer", { left: `${left}%`, ease: "none", duration: 0 });
},
});
Scrubbed with scrub:1 (≈1s smoothing). While .header-info travels from top-top to bottom-at-50%-of-viewport, the revealer's left interpolates 35% → 50%, sliding it to horizontal center. duration:0, ease:"none" inside the update, so the smoothing comes purely from scrub:1.
6. Scale the revealer up 1× → 13× to white-out the screen.
ScrollTrigger.create({
trigger: ".whitespace",
start: "top 50%",
end: "bottom bottom",
scrub: 1,
onUpdate: (self) => {
const scale = 1 + 12 * self.progress;
gsap.to(".revealer", { scale, ease: "none", duration: 0 });
},
});
Scrubbed scrub:1. Over .whitespace (from its top reaching 50% of the viewport to its bottom reaching the bottom), the now-solid white square scales 1 → 13, ballooning to cover the whole viewport — a white wipe. Because .website-content is a white panel at z-index:10, the enlarged white square hands off seamlessly into the final content section as it scrolls up.
Net choreography (top → bottom scroll)
Hero "Symphonia" → dark "Motion / Stills" headers scroll past → .header-info and .pinned both pin; the tiny white cross spins 360° while its clip-path expands into a solid square, meanwhile drifting left→center → then over the whitespace it scales ~13× into a full white-out → the white .website-content is revealed. All reversible on scroll-up (scrubbed triggers reverse; the two onUpdate-driven triggers track progress both ways).
Assets / images
- 1 hero background: a dark, atmospheric full-bleed photograph (landscape, cropped with
background-size:cover) — moody/cinematic, so the white "Symphonia" serif reads clearly over it. - 4 editorial thumbnails for the
.header-imagesrow: matched editorial/photographic images, each cropped into a wide cell (~250px tall,object-fit:cover), so roughly landscape 3:2–16:9 crops. They should read as one cohesive editorial set (elegant, muted).
No brand marks or logos — neutral placeholder imagery only. Total 5 images: hero.jpg plus img-1.jpg … img-4.jpg.
Behavior notes
- Page-level component: Lenis hijacks the whole-page scroll for smoothing.
- The tall section heights (
.info150vh,.whitespace300vh,.pinned250vh absolute attop:100vh) are what create the scroll runway — keep them; the ScrollTrigger start/end/endTrigger relationships depend on this stacking and on.pinnedbeingposition:absolute; top:100vh; z-index:2while.whitespaceisz-index:-1and.website-contentisz-index:10. - The two pins share the same end (
.whitespacebottom top), so the dark block and the revealer stay locked together through the whole reveal. - Rotation (trigger #3) is intentionally the only motion using GSAP's default eased
gsap.to(a soft ~0.5s catch-up); clip-path, left and scale all useduration:0, ease:"none"so they bind tightly to scroll (smoothing on #5/#6 comes fromscrub:1). - On
max-width:900pxthe revealer is re-centered (left:50%) and shrunk (100×100,margin-top:400px); headings shrink substantially. Effect still works because all revealer transforms are percentage/scale based.
</content> </invoke>