All components

We Go Again

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A three-column documentary-studio film selector where a vertical thumbnail gallery on the right lets you click any item to swap the featured project. On click, GSAP animates the current title/copy/credits lines out (y:-60, power4.in, staggered) while the featured image scales up and collapses away; on completion the details are rebuilt, SplitType re-splits the copy into lines, and the new text (y:40→0, power4.out, stagger) plus image (scale 0→1, power4.out) animate in, while the full-screen blurred background crossfades between the two images.

How it's built

Categorygallery
Techgsap, split-type
Complexitypage
Performance costlight
Mobile-safeyes

gallery gsap split-type image-transition stagger text-reveal backdrop-blur editorial showcase

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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; }

Containerposition: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:hidden clips 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 image object-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):

  1. const split = new SplitType(element, { types: "lines" });
  2. Clear element.innerHTML = "".
  3. For each split.lines: build <div class="line"><span>lineText</span></div> (line text = the split line's textContent) and append to element. So every visual line becomes a .line mask wrapping a <span> — the span is what animates, the .line clip-path masks it.
On load (init, inside DOMContentLoaded)
  1. createSplitText(document.querySelector(".info p")) — split item 0's copy into masked lines.
  2. Select the animatable set elementsToAnimate = ".title h1, .info p .line span, .credits p, .director p, .cinematographer p" and gsap.set(elementsToAnimate, { y: 0 }) — this cancels the CSS translateY(40px/20px) offsets, snapping item 0's text into its visible resting position (item 0 has no entrance animation; it just appears).
  3. Build the gallery: loop i from 0 to galleryItems.length-1, create .item (add class active only when i === 0), append an <img src="/c/we-go-again/img${i+1}.jpg">, set dataset.index = i, attach a click listener → 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 to y:-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 → simultaneously gsap.to(currentProjectImg's inner <img>, { scale:2, duration:1, ease:"power4.in" }). So the wrapper collapses scale 1→0 and lifts bottom 1em→10em while the inner image zooms in scale 1→2 (a push-in as the frame shrinks away upward).
  • onCompletePhase 2 (below).

(D) Phase 2 — rebuild + animate the NEW project IN (runs in Phase-1's onComplete, ~1s after click):

  1. Remove the old .project-details and old .project-img from the DOM.
  2. Build a fresh .project-details with 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-img with <img src="/c/we-go-again/img${index+1}.jpg">. Append both to .project-preview.
  3. createSplitText(newInfoP) — split the new copy into masked .line > span.
  4. Select the new set (.title h1, .info p .line span, .credits p, .director p, .cinematographer p within the new details) and:
  5. Text in: gsap.fromTo(newTextEls, { y:40 }, { y:0, duration:1, ease:"power4.out", stagger:0.05 }) — all rise from y:40 up to y:0, decelerating (power4.out), staggered 0.05s. (Note: every element animates from y:40, overriding the CSS 20px/40px offsets.)
  6. Featured wrapper in: gsap.fromTo(newProjectImg, { scale:0, bottom:"-10em" }, { scale:1, bottom:"1em", duration:1, ease:"power4.out" }) — grows scale 0→1 and rises bottom -10em→1em (comes up from below the frame).
  7. 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 out scale 2→1 to settle. Its onComplete releases the isAnimating lock.

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 isAnimating lock blocks re-clicks mid-transition; clicking the already-active item is a no-op.
  • The gallery column scrolls internally (native overflow:auto; the .gallery is 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).