We Go Again — Click-to-Swap Project Showcase
Goal
Build a full-viewport, three-column creative-agency showcase. The right column is a slim, internally-scrollable vertical thumbnail gallery; the wide middle column shows the currently featured project (big title, a paragraph of copy split into lines, credits, a large featured image); the left column holds the site nav + agency intro. Behind everything sits a full-screen, heavily-blurred version of the featured image as an ambient color wash. The star effect: clicking any thumbnail runs a two-phase GSAP transition — the current title/copy/credits lines slide up and out (staggered, accelerating) while the featured image shrinks, rises and zooms; then the details are rebuilt for the new project and the new text rises in from below while the new image grows up from below and zooms back to normal — and the blurred background crossfades between the two images the whole time.
Tech
Vanilla HTML/CSS/JS with ES module imports, bundled by Vite. No framework.
gsap(npm) — all motion. No GSAP plugins, no ScrollTrigger.split-type(npm,SplitType) — splits the copy paragraph into lines so each line can be masked/animated.
Import them as:
import gsap from "gsap";
import SplitType from "split-type";
There is no smooth-scroll library and no ScrollTrigger — the only scroll is native overflow: auto inside the gallery column.
Data model
All project content lives in a separate data.js that exports an array of ~15 objects, one per gallery item:
export const galleryItems = [
{ title: "Beyond The Summit",
copy: "One or two sentences of editorial description...",
director: "Alex Honnold",
cinematographer: "Jimmy Chin" },
// ...14 more with the same four fields
];
Fields: title (string), copy (a 2–3 sentence paragraph), director (name), cinematographer (name). Content is neutral placeholder film/documentary metadata — invent 15 varied entries. The number of gallery thumbnails is driven by galleryItems.length.
Layout / HTML
<div class="container">
<!-- full-screen blurred backdrop -->
<div class="blurry-prev">
<img src="<image 1>" alt="" />
<div class="overlay"></div>
</div>
<!-- LEFT column: nav + agency intro -->
<div class="col site-info">
<nav>
<a href="#">Home</a>
<a href="#">Work</a>
<a href="#">Contact</a>
</nav>
<div class="header"><h1>Welcome to Studio</h1></div>
<div class="copy">
<p>We are a full-service creative agency delivering innovative
design solutions for businesses around the globe.</p>
</div>
</div>
<!-- MIDDLE column: featured project (item 0 at load) -->
<div class="col project-preview">
<div class="project-details">
<div class="title"><h1>Beyond The Summit</h1></div>
<div class="info"><p>...item 0 copy paragraph...</p></div>
<div class="credits"><p>Credits</p></div>
<div class="director"><p>Director: Alex Honnold</p></div>
<div class="cinematographer"><p>Cinematographer: Jimmy Chin</p></div>
</div>
<div class="project-img"><img src="<image 1>" alt="" /></div>
</div>
<!-- RIGHT column: gallery (thumbnails injected by JS) -->
<div class="gallery-wrapper">
<div class="gallery"></div>
</div>
</div>
<script type="module" src="./script.js"></script>
The middle column's .project-details and .project-img for item 0 are hardcoded in HTML; every subsequent project is destroyed and rebuilt in JS. The gallery thumbnails are all created by JS.
"Welcome to Studio" and the agency blurb are neutral placeholders — no real brand marks.
Styling
Globals
* { margin:0; padding:0; box-sizing:border-box; }html, body { width:100vw; height:100vh; background:#0f0f0f; font-family:"PP Neue Montreal", sans-serif; }(use any clean neutral sans as fallback).img { width:100%; height:100%; object-fit:cover; }h1 { color:#fff; font-size:36px; font-weight:500; }a, p { color:#fff; font-size:16px; font-weight:500; text-decoration:none; }
Container — position:relative; width:100%; height:100%; display:flex; overflow:hidden; (the three columns are flex children; overflow:hidden clips the transitions).
Blurred backdrop — .blurry-prev { position:absolute; inset:0; width:100%; height:100%; } holds one or more <img> plus .overlay { position:absolute; inset:0; backdrop-filter:blur(80px); }. The overlay blurs the image(s) behind it into a soft ambient wash over the whole screen. (It sits at the bottom of the stacking order; the three columns render on top.)
Left column — .col { position:relative; padding:1em; }. .site-info { flex:1; display:flex; flex-direction:column; justify-content:space-between; border-right:1px solid rgba(255,255,255,0.1); }. nav { display:flex; gap:1em; }. .header { position:absolute; top:50%; transform:translateY(-50%); } (vertically centers the H1).
Middle column — .project-preview { flex:2; }.
.project-details { position:absolute; top:1em; left:1em; width:50%; }.title { margin-bottom:0.5em; }.info { margin-bottom:1em; }- Masks (essential):
.title, .credits, .director, .cinematographer, .line { clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); }— a full-box clip that acts as an overflow mask so text sliding vertically inside each block is clipped at the block's edges. - Initial hidden offsets (CSS):
.title h1 { position:relative; transform:translateY(40px); will-change:transform; }and.info p .line span, .credits p, .director p, .cinematographer p { display:inline-block; position:relative; transform:translateY(20px); will-change:transform; }. These push the text below its mask; JS snaps them into view at load (see effect). .project-img { position:absolute; left:1em; bottom:1em; width:75%; height:50%; overflow:hidden; will-change:transform; }with.project-img img { will-change:transform; }. The large featured image;overflow:hiddenclips its inner img zoom.
Right column (frosted gallery panel)
.gallery-wrapper { z-index:2; overflow:auto; padding:0.75em; background:rgba(255,255,255,0.1); border-left:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(20px); }— a frosted, scrollable strip..gallery { width:100px; height:300vh; display:flex; flex-direction:column; gap:0.75em; }— a tall stack (3× viewport) so the thumbnails scroll internally..item { position:relative; flex:1; background:#aeaeae; }— each thumbnail is a tall sliver, its imageobject-fit:cover.- Thumbnail dim/undim overlay:
.item::after { content:""; position:absolute; inset:0; background:rgba(0,0,0,0.65); transition:background-color 0.5s ease-in-out; transition-delay:0.5s; }and.item.active::after { background:rgba(0,0,0,0); }. So every thumbnail is darkened except the active one, which brightens via a 0.5s CSS transition that waits 0.5s before starting.
Responsive — @media (max-width:900px): .container becomes flex-direction:column; .site-info { flex:0.5; border-right:none; border-bottom:1px solid rgba(255,255,255,0.1); }; .header { top:unset; bottom:1em; transform:none; }; .site-info .copy { display:none; }; .project-details { width:calc(100% - 1em); }; .project-img { width:93%; }; .gallery-wrapper uses border-top instead of left; .gallery { width:300vw; height:100px; flex-direction:row; } (horizontal thumbnail strip).
The GSAP effect (be exhaustive)
SplitText helper
createSplitText(element):
const split = new SplitType(element, { types: "lines" });- Clear
element.innerHTML = "". - For each
split.lines: build<div class="line"><span>lineText</span></div>(line text = the split line'stextContent) and append toelement. So every visual line becomes a.linemask wrapping a<span>— the span is what animates, the.lineclip-path masks it.
On load (init, inside DOMContentLoaded)
createSplitText(document.querySelector(".info p"))— split item 0's copy into masked lines.- Select the animatable set
elementsToAnimate = ".title h1, .info p .line span, .credits p, .director p, .cinematographer p"andgsap.set(elementsToAnimate, { y: 0 })— this cancels the CSStranslateY(40px/20px)offsets, snapping item 0's text into its visible resting position (item 0 has no entrance animation; it just appears). - Build the gallery: loop
ifrom0togalleryItems.length-1, create.item(add classactiveonly wheni === 0), append an<img src="/c/we-go-again/img${i+1}.jpg">, setdataset.index = i, attach aclicklistener →handleItemClick(i), append to.gallery.
State variables: activeItemIndex = 0, isAnimating = false.
On thumbnail click — handleItemClick(index)
Guard: if (index === activeItemIndex || isAnimating) return; then isAnimating = true.
(A) Swap active class — remove active from gallery.children[activeItemIndex], add active to gallery.children[index], set activeItemIndex = index. (Fires the CSS ::after un-dim/dim: the new thumbnail brightens, the old darkens, each over 0.5s after a 0.5s delay.)
(B) Blurred background crossfade (ease: "power2.inOut", duration: 1, both delayed 0.5):
- Create a new
<img src="/c/we-go-again/img${index+1}.jpg">,gsap.set(newImg, { opacity:0, position:"absolute", top:0, left:0, width:"100%", height:"100%", objectFit:"cover" }), and insert it as the first child of.blurry-prev(insertBefore(newImg, blurryPrev.firstChild)). - Grab the previous backdrop image (now
.blurry-prev img:nth-child(2));gsap.to(prevImg, { opacity:0, duration:1, delay:0.5, ease:"power2.inOut", onComplete: () => remove it }). gsap.to(newImg, { opacity:1, duration:1, delay:0.5, ease:"power2.inOut" }).
(C) Phase 1 — animate the CURRENT project OUT (starts immediately, duration:1):
- Text out:
gsap.to(elementsToAnimate, { y:-60, duration:1, ease:"power4.in", stagger:0.05 })— every current title h1 / info line span / credits / director / cinematographer slides up toy:-60, accelerating (power4.in), cascading with a 0.05s stagger; the clip-path masks swallow them. - Featured image out:
gsap.to(currentProjectImg /* the .project-img wrapper */, { scale:0, bottom:"10em", duration:1, ease:"power4.in", onStart, onComplete }): onStart→ simultaneouslygsap.to(currentProjectImg's inner <img>, { scale:2, duration:1, ease:"power4.in" }). So the wrapper collapsesscale 1→0and liftsbottom 1em→10emwhile the inner image zooms inscale 1→2(a push-in as the frame shrinks away upward).onComplete→ Phase 2 (below).
(D) Phase 2 — rebuild + animate the NEW project IN (runs in Phase-1's onComplete, ~1s after click):
- Remove the old
.project-detailsand old.project-imgfrom the DOM. - Build a fresh
.project-detailswith the new item's data — same structure:.title h1,.info p(raw paragraph, not yet split),.credits p= "Credits",.director p=Director: <name>,.cinematographer p=Cinematographer: <name>. Build a fresh.project-imgwith<img src="/c/we-go-again/img${index+1}.jpg">. Append both to.project-preview. createSplitText(newInfoP)— split the new copy into masked.line > span.- Select the new set (
.title h1, .info p .line span, .credits p, .director p, .cinematographer pwithin the new details) and: - Text in:
gsap.fromTo(newTextEls, { y:40 }, { y:0, duration:1, ease:"power4.out", stagger:0.05 })— all rise fromy:40up toy:0, decelerating (power4.out), staggered 0.05s. (Note: every element animates fromy:40, overriding the CSS 20px/40px offsets.) - Featured wrapper in:
gsap.fromTo(newProjectImg, { scale:0, bottom:"-10em" }, { scale:1, bottom:"1em", duration:1, ease:"power4.out" })— growsscale 0→1and risesbottom -10em→1em(comes up from below the frame). - Featured inner img in:
gsap.fromTo(newProjectImg's <img>, { scale:2 }, { scale:1, duration:1, ease:"power4.out", onComplete: () => isAnimating = false })— the inner image zooms back outscale 2→1to settle. ItsonCompletereleases theisAnimatinglock.
Timing summary: total ≈ 2s — Phase 1 (out) 1s + Phase 2 (in) 1s; the backdrop crossfade overlaps both (0.5s delay + 1s). OUT eases are power4.in (accelerate away, upward); IN eases are power4.out (decelerate in, from below); backdrop is power2.inOut. Stagger is 0.05 on both text sets. No timeline object is used — these are parallel gsap.to/gsap.fromTo tweens coordinated by the wrapper tween's onStart/onComplete callbacks.
Assets / images
15 images named img1.jpg … img15.jpg, served from /c/we-go-again/. Each image plays three roles at once: (1) a tall vertical thumbnail in the ~100px-wide gallery (cover-cropped to a slim strip), (2) the large featured image (a 75%×50% landscape-ish framed box) when its item is active, and (3) the full-screen blurred ambient background when active.
Visually it's a curated high-end editorial / commercial mix — roughly half fashion & beauty portraits (studio-lit models against warm brown, grey or orange seamless backdrops, ~2:3 vertical) and half product / design still-lifes (matte cosmetic pump bottles, beverage cans, capsule containers, an amber dropper serum bottle on beige podiums under dramatic spotlights, plus a colorful mechanical keyboard, a curved metallic building, and a warm sunlit interior). Framing is mixed (portrait to square); object-fit:cover handles all crops. Because they are heavily blurred as backgrounds and sliver-cropped as thumbnails, any centered, moody editorial/commercial photo set works — keep subjects roughly centered. No brand names or logos on any product.
Behavior notes
- Interaction is click-only on gallery thumbnails; no hover, no scroll-trigger, no autoplay. Item 0 is active at load with no entrance animation.
- The
isAnimatinglock blocks re-clicks mid-transition; clicking the already-active item is a no-op. - The gallery column scrolls internally (native
overflow:auto; the.galleryis 300vh / 300vw tall) to reach all 15 thumbnails. - Responsive at
900px: layout stacks vertically and the gallery becomes a horizontal strip; otherwise the effect is identical. - No reduced-motion handling in the original. Fully client-side, light performance cost (no WebGL/canvas).