All components

Zoomable Image Wall

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-viewport mosaic wall of 1200 image tiles that reveals on load with a random GSAP grid stagger (scale + opacity, power1.out). Zoom-in/out buttons run a 2.5s power4.inOut GSAP tween that explodes each tile outward and scales it up, and a drag layer lets you pan the zoomed wall with a lerp-smoothed translate3d.

How it's built

Categorygallery
Techgsap
Complexitypage
Performance costmedium
Mobile-safeyes

gallery mosaic image-wall drag zoom stagger gsap editorial interactive

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

Zoomable Image Wall — random-stagger mosaic reveal + click-zoom explosion + drag-to-pan

Goal

Build a full-viewport, pitch-black mosaic wall of 1200 tiny image tiles that fills a canvas larger than the screen. On load, every tile pops in from scale:0 to scale:1 with a random grid stagger (the mosaic materialises tile-by-tile in no particular order). A floating pill of two buttons (zoom-out / zoom-in) toggles a 2.5s zoom: clicking zoom-in makes every tile explode radially outward from the viewport centre and blow up to 5× (you dive into the wall); clicking zoom-out reverses it. While zoomed, a transparent drag layer lets you click-drag to pan the enlarged wall, smoothed by a manual requestAnimationFrame lerp loop writing translate3d. No scroll, no ScrollTrigger, no GSAP plugins — just core gsap, a click state machine, and a rAF pan loop.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no plugins (no ScrollTrigger, no SplitText), no Lenis. Import as:

import gsap from "gsap";

All logic runs inside a DOMContentLoaded listener. The load reveal and the two zoom transitions use gsap.to / gsap.timeline; the drag-pan uses a plain lerp + requestAnimationFrame loop (not GSAP).

Layout / HTML

<body>
  <div class="logo"><p>Motionprompts</p></div>

  <div class="pads">
    <button id="zoom-out" class="active"><!-- magnifier-minus SVG --></button>
    <button id="zoom-in"><!-- magnifier-plus SVG --></button>
  </div>

  <div id="drag-layer"></div>

  <div class="container">
    <div class="gallery"></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>
  • .logo — small frosted pill fixed top-centre with a short uppercase wordmark (neutral text, e.g. "MOTIONPROMPTS").
  • .pads — frosted pill fixed bottom-centre holding two icon buttons. #zoom-out starts with class active (it is the current state on load — the wall begins zoomed OUT). Each button holds an 18×18 white-stroked SVG: #zoom-out a magnifier with a minus, #zoom-in a magnifier with a plus.
  • #drag-layer — full-viewport transparent overlay, display:none by default; shown only while zoomed to capture drag input.
  • .container.gallery — the oversized stage and the flex-wrap mosaic. The 1200 tile <div class="img"> elements (each wrapping an <img>) are created in JS, not in the HTML.

Styling

Global reset * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; background:#000; overflow:hidden; } — the whole page is black and never scrolls. img { width:100%; height:100%; object-fit:cover; user-select:none; }.

  • .logo: position:fixed; left:50%; transform:translateX(-50%); width:20%; margin:2em auto; padding:8px 0; text-align:center; background-color:rgba(255,255,255,0.7); backdrop-filter:blur(20px); border-radius:8px; z-index:2;. Its p: text-transform:uppercase; font-family:"PP Neue Montreal", sans-serif; (any clean neutral grotesque sans is fine) font-size:12px; font-weight:500;.
  • .pads: position:fixed; left:50%; bottom:2em; transform:translateX(-50%); padding:0 4px; display:flex; gap:0.2em; background-color:rgba(255,255,255,0.25); border:1px solid rgba(255,255,255,0.125); backdrop-filter:blur(20px); border-radius:8px; z-index:2;.
  • .pads button: opacity:1; outline:none; border:none; background:none; padding:8px; transition:0.5s opacity; pointer-events:all;.
  • .pads button.active: opacity:0.5; pointer-events:none; — the active (current) button is dimmed and un-clickable; the other one is the live control.
  • .container: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:150vw; height:150vh; overflow:visible;a stage 1.5× the viewport in both axes, centred, so the mosaic bleeds well past the screen edges.
  • .gallery: position:relative; width:100%; height:100%; display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:4px; padding:4px; transform-origin:center center; will-change:transform; — a centred flex-wrap grid of the tiles. The gallery's own transform is used exclusively by the drag-pan loop.
  • .img (each tile): width:calc((100% - 236px) / 60); transform:scale(0); transform-origin:center center; will-change:transform; pointer-events:none; opacity:0; — starts invisible and collapsed. The width math packs exactly 60 tiles per row: 236px = 59 gaps × 4px, so (100% − total gaps) / 60 = one tile width. Each tile's height is set inline in JS to a random 30–40px (so rows have slightly ragged, editorial heights).
  • #drag-layer: position:fixed; top:0; left:0; width:100vw; height:100vh; display:none; touch-action:none; z-index:1;.

Building the mosaic (JS — do this first)

const totalRows = 20;
const imagesPerRow = 60;
const totalImages = totalRows * imagesPerRow; // 1200

Loop i from 0 to 1199:

  • Create div.img; set img.style.height = ${randomInt(30,40)}px (inclusive random).
  • Create an <img>; src = "/c/sarah-bassett/img" + (randomInt(1,50)) + ".jpg"each tile is randomly assigned one of 50 source images, so the wall is a repeating-but-shuffled mosaic. (In a fresh project, point these at your own image folder.)
  • Append <img>div.img.gallery; push each div.img into an images[] array.

GSAP effect (the core — be exhaustive)

1. Load reveal — random grid stagger (fires once, on load)
gsap.to(images, {
  scale: 1,
  opacity: 1,
  duration: 0.5,
  delay: 1,
  ease: "power1.out",
  stagger: {
    amount: 1.5,          // total spread of all 1200 start times = 1.5s
    grid: [totalRows, imagesPerRow], // [20, 60] — GSAP treats the array as a 2D grid
    from: "random",       // start times assigned in random grid order
  },
});
  • Every tile animates scale: 0 → 1 and opacity: 0 → 1 over 0.5s with power1.out.
  • delay: 1 — the whole wall waits 1s (blank black), then begins.
  • stagger.amount: 1.5 spreads the 1200 start times across a 1.5s window; from: "random" with grid: [20, 60] means tiles pop in in a scattered, non-sequential order (the mosaic "develops" like film). Total reveal ≈ 1s delay + 1.5s spread + 0.5s tail ≈ 3s.
  • No timeline — a single tween across the images[] array.
2. Zoom IN — radial explosion to 5× (on #zoom-in click)

Guard: if already zoomed, return. Set isZoomed = true, show #drag-layer (display:block). Then per tile, read its current on-screen position and tween it outward from the viewport centre:

images.forEach((img) => {
  const rect = img.getBoundingClientRect();
  const centerX = window.innerWidth / 2;
  const centerY = window.innerHeight / 2;
  const distX = (rect.left + rect.width / 2 - centerX) / 100; // signed, /100
  const distY = (rect.top  + rect.height / 2 - centerY) / 100;

  gsap.to(img, {
    x: distX * 1200,   // horizontal blast is 2× stronger than vertical
    y: distY * 600,
    scale: 5,
    duration: 2.5,
    ease: "power4.inOut",
  });
});
  • Each tile's displacement is proportional to its distance from the viewport centre — centre tiles barely move, edge tiles fly far — producing a radial "dive into the wall" explosion. Net horizontal factor = (dist/100)*1200 = dist*12; vertical = dist*6 (so the blast is anamorphic, wider than tall).
  • All tiles scale 1 → 5, over 2.5s, power4.inOut (slow-in / slow-out, punchy middle).
  • Swap button states: remove active from #zoom-out, add active to #zoom-in.
3. Zoom OUT — collapse back home (on #zoom-out click)

Guard: if not zoomed, return. Set isZoomed = false, hide #drag-layer. Capture and neutralise any drag offset, then reverse both the tile explosion and the gallery pan simultaneously:

const currentTransform = getComputedStyle(gallery).transform; // whatever the drag left
gsap.set(gallery, { clearProps: "transform" });

const tl = gsap.timeline({ defaults: { duration: 2.5, ease: "power4.inOut" } });
tl.fromTo(gallery, { transform: currentTransform }, { x: 0, y: 0 })   // pan the wall back to origin
  .to(images, { scale: 1, x: 0, y: 0 }, 0);                            // AND collapse tiles home, at position 0
  • Position param 0 on the second tween → both run together from the timeline start (the gallery slides back to translate(0,0) while every tile shrinks 5 → 1 and returns to x:0, y:0).
  • Same 2.5s / power4.inOut as zoom-in.
  • Reset the pan state variables (currentX = currentY = targetX = targetY = 0; isDragging = false;).
  • Swap button states back: add active to #zoom-out, remove from #zoom-in.
4. Drag-to-pan — manual lerp loop (active only while zoomed)

Not GSAP — a hand-rolled interpolation loop that writes the gallery's transform:

function lerp(start, end, factor) { return start + (end - start) * factor; }

let isDragging=false, startX=0, startY=0, initialX=0, initialY=0,
    targetX=0, targetY=0, currentX=0, currentY=0;

function animate() {
  if (isDragging || Math.abs(targetX-currentX) > 0.01 || Math.abs(targetY-currentY) > 0.01) {
    currentX = lerp(currentX, targetX, 0.075);   // lerp factor 0.075 → smooth, weighty glide
    currentY = lerp(currentY, targetY, 0.075);
    requestAnimationFrame(() => {
      gallery.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
    });
  }
  requestAnimationFrame(animate);
}
animate();

Pointer/touch handlers on #drag-layer (mouse + touch):

  • drag start (mousedown / touchstart): only if isZoomed. Record startX/startY from the pointer; read the gallery's current translate via new DOMMatrix(getComputedStyle(gallery).transform)m41/m42 into initialX/initialY, and seed currentX/Y = targetX/Y = initialX/Y. Attach move/end listeners (passive:false on move so preventDefault works).
  • drag move: targetX = initialX + (pointerX − startX), targetY = initialY + (pointerY − startY). The lerp loop chases the target, so the wall follows the cursor with inertia.
  • drag end: isDragging = false, detach the move/end listeners. The loop keeps easing to rest, then idles.

No CustomEase; the only easings are GSAP's power1.out (reveal) and power4.inOut (both zooms), plus the manual 0.075 lerp for panning.

Assets / images

50 interchangeable source images (img1…img50), each rendered inside a tiny object-fit:cover tile roughly ~24×30–40px (portrait-ish, ~2:3-to-3:4), and each randomly reused many times across the 1200-tile wall — so at wall scale they read as a dense, colourful editorial mosaic, and only become legible photographs once you zoom in 5×. They form one cohesive high-fashion studio-editorial set: bold, saturated, warm-lit fashion photography — models embracing/posing, cropped accessory and still-life shots (handbags, sunglasses, a single high heel, a vintage phone/radio, a disco ball), and moody light-beam portraits. Recurring palette of deep reds, teals, greens, warm yellows and neutral studio tones. Provide 50 files named sequentially; if fewer are available, repeat them — the mosaic is intentionally random and repeating anyway. No logos or brand marks.

Behavior notes

  • State machine: exactly two states, out (default) and in. The dimmed .active button marks the current state and is un-clickable; the live button is the other one. Clicks are ignored if already in that state (the early-return guards).
  • Drag only works while zoomed in (#drag-layer is display:none when zoomed out, and handlers early-return unless isZoomed). Both mouse and touch are supported (touch-action:none on the layer).
  • No scroll anywherebody { overflow:hidden }, the stage is position:absolute. Nothing is scroll-driven.
  • Heavy tile count (1200 DOM nodes); will-change:transform on tiles and gallery, and the reveal/zoom lean on GPU transforms. Reasonable on desktop; on mobile it still functions (touch drag works) but is the perf-sensitive part.
  • No reduced-motion handling in the original; the load reveal autoplays once, everything else is user-triggered.

Images

This component ships with 50 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/sarah-bassett/img1.jpg
https://motionprompts.dev/c/sarah-bassett/img10.jpg
https://motionprompts.dev/c/sarah-bassett/img11.jpg
https://motionprompts.dev/c/sarah-bassett/img12.jpg
https://motionprompts.dev/c/sarah-bassett/img13.jpg
https://motionprompts.dev/c/sarah-bassett/img14.jpg
… 44 more under https://motionprompts.dev/c/sarah-bassett/

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--ink, --paper, --paper-dim, --red, --glass, --glass-line. 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.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: mount(config) reaches into document directly and only unwinds because something calls the destroy() it returns. React withdraws the guarantee that something *will* call it at the right moment, and it does so quietly — the wall renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. mount() as written builds 1,200 fresh .img nodes and appends them to .gallery every time it runs; call it twice without running the matching destroy() in between and the wall doubles to 2,400 tiles, the reveal tween fires across two overlapping images[] arrays, and two drag-pan loops write competing translate3d values onto the same .gallery element. None of this reproduces in a production build, because React only does the double mount in development — treat the destroy() this function already hands you as load-bearing, not optional.

*(1) The entry point.* The guard at the bottom of the file — checking document.readyState before subscribing to DOMContentLoaded — exists to survive being loaded late into a plain document; a useEffect already runs after the DOM is committed, so drop the guard and the listener both. Drop the window.MP.register branch above it too: that path is this catalogue's own visual-editor hook, not part of the component, and a React host has no window.MP to satisfy it. What survives is exactly mount(config), and it already returns the right shape — a factory you call once and a teardown you keep:

useEffect(() => {
  const destroy = mount(config);
  return () => destroy();
}, []);

*(2) Element lookups.* .gallery, #zoom-out, #zoom-in and #drag-layer are all found with an unscoped querySelector/getElementById, on the assumption that exactly one of each exists in the document. The two ids are the sharper failure mode: mount two copies of this component (or just survive the StrictMode remount, which briefly leaves two copies of the subtree in the DOM) and both calls to getElementById("zoom-in") resolve to whichever node comes first in document order — so the second instance's zoom button silently drives the first instance's wall instead of its own. Give the component a root ref on the wrapper that contains .gallery and the button pair, and scope every one of those four lookups to it.

*(3) Cleanup.* The script already hands you a destroy() more thorough than a bare gsap.context revert would be, and the parts a context can't cover are exactly the parts this component leans on hardest.

The load reveal is caught for free; the two zoom tweens are not, unless you register them. Wrap the body of mount() in gsap.context((self) => { ... }, rootRef). The initial gsap.to(images, { ... }) reveal runs synchronously while the factory executes, so ctx.revert() sweeps it automatically. onZoomIn and onZoomOut are click handlers — their gsap.to and gsap.timeline calls run long after the factory has returned, on a click GSAP has no way to associate with the context unless told to. Register both by name with self.add, inside the factory, so they replay inside the context when the click actually happens — and call the two buttons against ctx, never against the bare functions, from outside the factory where ctx is already assigned:

const ctx = gsap.context((self) => {
  gsap.to(images, { /* the load reveal, exactly as above */ });
  self.add("zoomIn", onZoomIn);
  self.add("zoomOut", onZoomOut);
}, rootRef);
zoomInBtn.addEventListener("click", () => ctx.zoomIn());
zoomOutBtn.addEventListener("click", () => ctx.zoomOut());

Skip this and the explosion-out / collapse-back timelines survive their own ctx.revert() — the zoom still works on first mount, it just isn't torn down with everything else.

ctx.revert() doesn't remove the 1,200 tiles it animated, and it doesn't touch a single listener. It undoes tweens, timelines and the inline transform/opacity/scale GSAP wrote — it has no idea these particular nodes were created by this mount and belong nowhere else on the page. Keep the rest of the existing teardown exactly as written: images.forEach((img) => img.remove()); the four listeners on the buttons and the drag layer; and — because the drag handlers attach mousemove/touchmove/mouseup/touchend to document mid-gesture rather than up front — removing those same four from document unconditionally in the same cleanup, even if no drag is in progress when the component unmounts, exactly as the original already does defensively.

Both requestAnimationFrame handles need cancelling, not one. The pan loop reschedules itself (rafLoop) and, on every frame it actually moves the wall, schedules a second frame just to write the transform (rafWrite). Cancel only the loop and the pending write still lands — one more gallery.style.transform write arriving after destroy() has already reset the element. Keep both handles and cancel both in the same cleanup, before nulling them, exactly as the vanilla teardown does.

None of the async-effect or resolved-after-unmount rules apply here: mount() has no .then() and waits on no font or image load — every side effect it creates happens synchronously inside the call, which is exactly why folding it into a gsap.context is a clean fit rather than a rewrite.