Clayboan Scroll Gallery — Clip-Path Reveal + Char-by-Char Titles
Goal
Build a full-page vertical scroll gallery (portfolio style) with Lenis smooth scroll where each tall "work" section is scrubbed by GSAP ScrollTrigger. As a section enters the viewport, its full-bleed image's clip-path morphs from an angular slanted polygon into a full rectangle (a diagonal wipe-open reveal); as the section leaves, the clip-path morphs again into a bottom-slanted polygon (a diagonal wipe-close). Meanwhile the big white project title, centered over the image, animates in character by character: SplitText masks every char and each char slides up from below its mask, with each character bound to its OWN scroll window so the reveal cascades left-to-right as you scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scroll. Register with gsap.registerPlugin(ScrollTrigger, SplitText). Run everything inside a DOMContentLoaded handler.
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<section class="hero">
<h1>Beyond the limits</h1>
</section>
<!-- repeat this block 5 times, one per project -->
<section class="work-item">
<div class="work-item-img"><img src="..." alt="" /></div>
<div class="work-item-name">
<h1>Carbon Edge</h1>
</div>
</section>
<section class="outro">
<h1>Back to base</h1>
</section>
- Exactly 5
.work-itemsections between the hero and the outro. - Project titles, in order: Carbon Edge, Velocity Grid, Aeroform, Mach Horizon, Titan Rail.
- Hero copy: "Beyond the limits". Outro copy: "Back to base". No nav, no footer — just these 7 stacked sections.
Styling
Font (Google Fonts): Inter Tight (variable weight import). Body: font-family: "Inter Tight", sans-serif; background: #fcfcfc; color: #141414.
Global:
* { margin:0; padding:0; box-sizing:border-box }.img { width:100%; height:100%; object-fit:cover }.h1 { text-transform:uppercase; text-align:center; font-size:5rem; font-weight:550; line-height:1 }(weight 550 works because Inter Tight is a variable font).section { position:relative; width:100vw; overflow:hidden }.
Sections:
.hero, .outro { height:100svh; display:flex; justify-content:center; align-items:center; padding:2rem }— dark text on the off-white page background..work-item { height:150svh }— each project section is 1.5 viewport heights tall, which creates the scroll runway for the scrubbed animations..work-item-img { position:absolute; width:100%; height:100%; will-change:clip-path }with initialclip-path: polygon(25% 25%, 75% 40%, 100% 100%, 0% 100%)(an irregular quadrilateral: top edge slanted downward left-to-right, full bottom). The image fills the whole 150svh section behind the title..work-item-name { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; z-index:1 }— the title is centered in the section, layered above the image..work-item-name h1 { color:#fff }(white title over the photo).- Media query
max-width: 1000px: allh1(including.work-item-name h1) drop tofont-size: 2.5rem.
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 the scroll; GSAP's ticker drives Lenis (time is in seconds, Lenis wants ms, hence * 1000).
Per work item
Loop gsap.utils.toArray(".work-item"). For each item, grab img = item.querySelector(".work-item-img") and nameH1 = item.querySelector(".work-item-name h1"), then create THREE kinds of scrubbed ScrollTriggers:
1. Title chars — one ScrollTrigger per character.
const split = SplitText.create(nameH1, { type: "chars", mask: "chars" });
gsap.set(split.chars, { y: "125%" });
mask: "chars" wraps every char in its own overflow-clipping mask element, so y: "125%" hides each char fully below its own line box. Then, for each char at index:
ScrollTrigger.create({
trigger: item,
start: `top+=${index * 25 - 250} top`,
end: `top+=${index * 25 - 100} top`,
scrub: 1,
animation: gsap.fromTo(char, { y: "125%" }, { y: "0%", ease: "none" }),
});
- Each character gets its own 150px scroll window, offset 25px later per index — that per-index offset is what produces the left-to-right cascade (it is a scroll-position stagger, not a time stagger).
- The negative base offsets (
-250/-100) mean the first characters start revealing BEFORE the section top reaches the viewport top (i.e. while the section is still entering), and scrubbing back down reverses them. scrub: 1gives the chars a ~1s catch-up smoothing.
2. Image reveal on enter.
ScrollTrigger.create({
trigger: item,
start: "top bottom",
end: "top top",
scrub: 0.5,
animation: gsap.fromTo(
img,
{ clipPath: "polygon(25% 25%, 75% 40%, 100% 100%, 0% 100%)" },
{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", ease: "none" }
),
});
While the section travels from "top hits viewport bottom" to "top hits viewport top", the slanted top edge of the polygon unfolds up into the full rectangle — the photo wipes open diagonally. ease: "none" + scrub keeps it perfectly tied to scroll; scrub: 0.5 adds a half-second smoothing lag.
3. Image conceal on exit.
ScrollTrigger.create({
trigger: item,
start: "bottom bottom",
end: "bottom top",
scrub: 0.5,
animation: gsap.fromTo(
img,
{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" },
{ clipPath: "polygon(0% 0%, 100% 0%, 75% 60%, 25% 75%)", ease: "none" }
),
});
While the section's bottom travels from viewport bottom to viewport top (the section leaving upward), the BOTTOM edge folds up into a slanted polygon (full top edge kept, bottom collapsing to points at 60%/75% height) — the mirror-image wipe-close of the entrance.
Important: both image triggers animate the same element's clipPath in separate non-overlapping scroll ranges (enter range ends where the section is fully on screen; exit range starts when its bottom reaches the viewport bottom), so they hand off cleanly. All polygons have 4 points so the morph interpolates smoothly. No pinning anywhere — the sections scroll naturally; the 150svh height is what stretches the effect out.
Assets / images
5 full-bleed photographs, one per work item, each filling a 150svh × 100vw section with object-fit: cover — so use large landscape-or-taller images (roughly 4:3 to 3:4 works; they'll be cropped by cover). They should read as a matched editorial set: dramatic, dark, industrial "engineering & speed" photography — think close-ups of aircraft engines, turbine intakes, streamlined land-speed vehicles, high-speed train noses and angular jet fuselages, mostly monochrome/desaturated with hard studio lighting on dark backgrounds. White or near-white machine bodies against black work best under the white titles.
Behavior notes
- Everything is scroll-driven and fully scrubbed (reversible): scrolling back down plays every reveal backwards. Nothing autoplays on load except that the hero is static text.
- No pinning, no scroll hijacking beyond Lenis smoothing.
- Uses
100svh/150svhso mobile browser chrome doesn't cause jumps. - Responsive is handled purely by the
max-width: 1000pxfont-size drop; the clip-path and char animations are percentage/offset based and work at any size. - Keep
will-change: clip-pathon.work-item-img— the clip morphs are the heaviest part.