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).

Images

This component ships with 8 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/we-go-again/img1.jpg
https://motionprompts.dev/c/we-go-again/img2.jpg
https://motionprompts.dev/c/we-go-again/img3.jpg
https://motionprompts.dev/c/we-go-again/img4.jpg
https://motionprompts.dev/c/we-go-again/img5.jpg
https://motionprompts.dev/c/we-go-again/img6.jpg
… 2 more under https://motionprompts.dev/c/we-go-again/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bg, --ink, --ink-dim, --ink-faint, --accent, --hairline. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and here the failure is easy to miss, because the two-phase click transition still looks right the first time anyone fires it — it only misbehaves on the click after that, or after a remount nobody watches fail.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Nothing here undoes what the first pass builds, so the second pass appends its own fifteen .item thumbnails after the first pass's fifteen — thirty children in .gallery — each batch closed over its own activeItemIndex and isAnimating, each batch's handleItemClick trusting that gallery.children[i] is the node index i was assigned to. It no longer is: click a thumbnail from the second batch and its handler flips the active class on a node that belongs to the first batch instead of the one actually clicked, and because the two batches' isAnimating flags are independent, nothing stops both from running the two-phase transition at once. The same remount hands createSplitText the item-0 paragraph a second time — except by then it no longer holds the plain sentence this helper expects, it holds the first pass's .line/<span> structure standing in for it. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for DOMContentLoaded with no readyState guard. By the time a React component mounts, that event has already fired, so the listener is never called and nothing here runs — no gallery, no featured project, nothing to debug. Delete the listener and move its entire body — the gallery-building loop, and the eventual createSplitText/gsap.set pair over the elementsToAnimate selection (.title h1, .info p .line span, .credits p, .director p, .cinematographer p) for item 0 — into a useEffect with an empty dependency array. activeItemIndex and isAnimating should stay exactly what they already are, plain let bindings local to the effect, not useState: neither is read by JSX, both only ever gate a synchronous click handler, and promoting either to state would re-render the tree on every click for no benefit.

*(2) Element lookups* — gallery, blurryPrev and projectPreview are three separate document.querySelector calls made once at setup; document.querySelector(".project-img") and document.querySelector(".project-details") inside handleItemClick, plus the elementsToAnimate NodeList it rebuilds on every call, repeat the same assumption on every click. Give the component's outermost element — the one this markup calls .container — a root ref, and resolve all five from it instead of from document. This isn't cosmetic here: gallery.children[activeItemIndex] is positional, not id-based, so anything that changes how many children .gallery actually has — including an unscoped lookup binding to a copy of this subtree that's on its way out during the StrictMode remount — desyncs the index handleItemClick trusts from the node it's actually supposed to point at.

*(3) Cleanup* — this component builds its animations from exactly one place, handleItemClick, but it builds them later than the click that triggers it: the exit tween's own onStart schedules a second tween, and its onComplete — firing roughly a second after the click — is where the entire incoming project gets rebuilt and animated. Both are the deferred-registration case self.add exists for, and this component needs it twice over, not once.

Wrap the gallery-building loop and the click handler in a gsap.context scoped to the root ref, and register handleItemClick as a named method so the thumbnails' listeners can call ctx.handleItemClick(i) instead of a free-standing function:

useEffect(() => {
  const root = rootRef.current;
  const infoText = root.querySelector(".info p")?.textContent ?? "";
  let cancelled = false;
  let self;

  const ctx = gsap.context((s) => {
    self = s;
    // the gallery-building loop, exactly as above, then:
    self.add("handleItemClick", (index) => {
      // the activeItemIndex/isAnimating guard, the active-class swap, the
      // blurry-backdrop crossfade and the exit tween on elementsToAnimate,
      // exactly as constructed above
      gsap.to(currentProjectImg, {
        onStart: () => self.add(() => {
          // the inner scale-up tween on currentProjectImgElem
        }),
        onComplete: () => self.add(() => {
          // remove the old project-details/project-img, build the new ones,
          // createSplitText(newInfoP), and the three entrance tweens,
          // exactly as constructed above
        }),
        // same scale, bottom, duration and ease this tween already specifies above
      });
    });
  }, root);

  root.querySelectorAll(".gallery .item").forEach((item, i) =>
    item.addEventListener("click", () => ctx.handleItemClick(i))
  );

  document.fonts.ready.then(() => {
    if (cancelled) return;
    self.add(() => {
      createSplitText(root.querySelector(".info p"));
      gsap.set(
        root.querySelectorAll(
          ".title h1, .info p .line span, .credits p, .director p, .cinematographer p"
        ),
        { y: 0 }
      );
    });
  });

  return () => {
    cancelled = true;
    const infoP = root.querySelector(".info p");
    if (infoP) infoP.textContent = infoText;
    ctx.revert();
    root.querySelector(".gallery")?.replaceChildren();
  };
}, []);

self.add("handleItemClick", …) makes everything handleItemClick builds synchronously when it's called — the blurry-backdrop crossfade, the exit tween on elementsToAnimate — tracked automatically. It doesn't reach the two things that fire later, off GSAP's own ticker rather than off the call to ctx.handleItemClick: the inner scale-up tween onStart builds, and the entire rebuilt-project entrance — createProjectDetails, createSplitText, the three fromTo tweens — that onComplete builds a second or so afterward. Wrap each of those in its own single-argument self.add(() => { … }), which runs the callback immediately, inside the context, the moment GSAP actually calls it. Skip that and ctx.revert() on unmount only ever reaches the first half of a transition; a click made just before navigating away can leave the incoming project's entrance tweens running against a .project-preview React has already torn down.

*Splitting .info p, and why only one of its two call sites is at risk.* createSplitText runs twice over this component's life: once at setup, against the item-0 paragraph that ships as static markup in this component's own JSX, and once per click, against a <p> createProjectDetails has just created and never split before. The second call site can't double-split anything — the node is new every time. The first can: createSplitText doesn't keep the SplitType instance it builds, or call .revert() on it — it reads .lines, discards the instance, then rebuilds the paragraph by hand from line.textContent. There's no instance left to revert, so what has to be undone instead is the text itself: capture .info p's original textContent before the first split (infoText, above) and write it back in the cleanup, so a StrictMode remount hands the second createSplitText call the sentence, not the first call's .line/<span> output standing in for it. This matters only for the double-invoke: once a real click has swapped in a different project, whatever .info p exists at unmount belongs to a createProjectDetails call the unmounting subtree already owns outright, with no original text worth restoring.

Line-based splitting also measures against whichever face is actually painted, and .info p renders in this component's own display sans — gate the setup split behind document.fonts.ready, guarded by the same cancelled flag the cleanup sets, so a .then() that resolves after a StrictMode unmount doesn't split a paragraph this component no longer owns. The per-click split needs no such gate: by the time anyone can click a thumbnail, the font has been resolved for as long as the component has existed.

root.querySelector(".gallery")?.replaceChildren() is what makes the thumbnail count idempotent: it removes every .item the previous run appended, listeners included — a node's listeners go with it once it's detached, there's nothing left to unbind by hand. Skip it and the StrictMode remount's fresh fifteen land on top of a .gallery that already has fifteen, which is exactly the doubled-batch failure described above.