All components

Infinite Draggable Image Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

An endless, draggable grid of thumbnails that recycles DOM items as you pan (custom lerp animation loop with velocity-based momentum on release). Clicking a thumbnail hides it, dims the rest, and expands a clone to a large frame via a GSAP fromTo tween on a CustomEase 'hop', while a SplitType word-by-word project title staggers up; clicking the overlay reverses the animation.

How it's built

Categorygallery
Techgsap, split-type
GSAP pluginsCustomEase
Complexitypage
Performance costmedium
Mobile-safeyes

draggable infinite gallery grid momentum lerp splittype customease expand

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

Infinite Draggable Image Gallery

Goal

Build a full-viewport, infinitely draggable image gallery: an endless grid of small portrait thumbnails on a pale canvas that you grab and pan in any direction forever (DOM items are virtualized — created/destroyed as they enter/leave a buffered viewport), with a lerp-smoothed drag and velocity-based momentum on release. Clicking a thumbnail is the star moment: the tile hides, every other tile fades out, a pale overlay closes in, and a fixed clone of the image expands from the thumbnail's exact spot to a large centered frame via a single GSAP fromTo tween on a custom "hop" ease, while the project's title staggers up word-by-word from behind a clip mask (SplitType). Clicking the expanded image or the overlay reverses everything back into the grid.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase, and split-type (npm, default export SplitType) for the word splitting. No ScrollTrigger, no Lenis — the page never scrolls (body { overflow: hidden }); all motion is a custom requestAnimationFrame lerp loop plus GSAP tweens. Ship index.html, styles.css, an ES-module script.js, and a tiny items.js data module.

Register and create the custom ease once at startup:

gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "0.9, 0, 0.1, 1");

"hop" = cubic-bezier(0.9, 0, 0.1, 1): a heavy slow-in / slow-out snap used for both the expand and the collapse.

Layout / HTML

<nav>
  <div class="logo"><a href="#">Motionprompts</a></div>
  <div class="links">
    <a href="#">About</a>
    <a href="#">Contact</a>
    <div class="socials">
      <a href="#">FB</a><a href="#">IG</a><a href="#">YT</a>
    </div>
  </div>
</nav>

<footer>
  <p>Experiment 445</p>
  <p>Drag to Explore</p>
</footer>

<div class="container">
  <div class="canvas" id="canvas"></div>
  <div class="overlay" id="overlay"></div>
</div>

<div class="project-title"><p></p></div>

<script type="module" src="./script.js"></script>
  • .container — the full-viewport drag surface (all mouse/touch listeners hang off it).
  • .canvas — the infinite plane; the JS translates this element and appends/removes .item tiles inside it.
  • .overlay — a full-screen pale scrim toggled with a CSS class during the expanded state.
  • .project-title p — starts empty; the JS injects the clicked project's title and splits it into words.

items.js exports a default array of 20 short, evocative two-word project titles (index i pairs with image i+1), e.g.:

const items = [
  "Chromatic Loopscape", "Solar Bloom", "Neon Handscape", "Echo Discs",
  "Void Gaze", "Gravity Sync", "Heat Core", "Fractal Mirage",
  "Nova Pulse", "Sonic Horizon", "Dream Circuit", "Lunar Mesh",
  "Radiant Dusk", "Pixel Drift", "Vortex Bloom", "Shadow Static",
  "Crimson Phase", "Retro Cascade", "Photon Fold", "Zenith Flow",
];
export default items;

Styling

  • Import the Google font Inter (variable weights). * { margin:0; padding:0; box-sizing:border-box; user-select:none; }
  • body: font-family:"Inter",sans-serif; background-color:#e3e3db; overflow:hidden; — a warm pale-grey/bone canvas.
  • a, p: display:block; text-decoration:none; color:#fff; font-size:14px; font-weight:600; letter-spacing:-0.01rem; -webkit-font-smoothing:antialiased;
  • nav, footer: position:absolute; left:0; width:100vw; padding:1em; display:flex; justify-content:space-between; gap:2em; mix-blend-mode:difference; z-index:10000; — nav pinned top:0, footer bottom:0. The mix-blend-mode:difference makes the white UI text invert over whatever passes beneath (dark thumbnails vs pale background).
  • .links, .socials: display:flex; gap:2em; and nav > *, .links a { flex:1; }
  • .container: position:relative; width:100vw; height:100vh; overflow:hidden; cursor:grab;
  • .canvas: position:absolute; will-change:transform;
  • .item: position:absolute; width:120px; height:160px; overflow:hidden; background-color:#000; cursor:pointer; — small 3:4 portrait tiles.
  • .expanded-item: position:fixed; z-index:100; top:50%; left:50%; transform:translate(-50%,-50%); background-color:#e3e3db; overflow:hidden; cursor:pointer; — the clone lives dead-center; GSAP x/y offsets move it relative to the viewport center.
  • img: width:100%; height:100%; object-fit:cover; pointer-events:none;
  • .overlay: position:fixed; top:0; left:0; width:100%; height:100%; background-color:#e3e3db; pointer-events:none; transition:opacity 0.3s ease; opacity:0; z-index:2; — with .overlay.active { pointer-events:auto; opacity:1; }. Note z-index 2: it covers the grid but sits *under* the .expanded-item (z 100) and the nav/footer/title (z 10000).
  • .project-title: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center; pointer-events:none; z-index:10000;
  • .project-title p: position:relative; height:42px; color:#fff; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); — the fixed-height box + clip-path is the mask the words rise out of.
  • .project-title p .word: position:relative; display:inline-block; font-family:"Inter"; font-size:36px; letter-spacing:-0.02rem; margin-right:0.1em; transform:translateY(0%); will-change:transform; (the .word class is what SplitType produces).

GSAP effect — be exhaustive

Constants & state
const itemCount = 20;   // distinct images
const itemGap   = 150;  // px between tiles
const columns   = 4;    // used only in the image-index formula
const itemWidth = 120, itemHeight = 160;

State variables: isDragging, startX/startY, targetX/targetY (where the canvas wants to be), currentX/currentY (where it is), dragVelocityX/Y, lastDragTime, mouseHasMoved, visibleItems (a Set of ids), lastUpdateTime, lastX/lastY, isExpanded, activeItem, canDrag (starts true), originalPosition, expandedItem, activeItemId, titleSplit.

1) The infinite pan — rAF lerp loop

An endless requestAnimationFrame loop animate():

  • Only while canDrag: lerp with factor 0.075

currentX += (targetX - currentX) * 0.075; (same for Y), then canvas.style.transform = translate(currentX px, currentY px).

  • Track how far the canvas moved since the last virtualization pass; call updateVisibleItems() when distance moved > 100px OR more than 120ms elapsed, then store lastX/lastY/lastUpdateTime.
  • Always re-queue requestAnimationFrame(animate).

Mouse drag (listeners: mousedown on .container, mousemove/mouseup on window):

  • mousedown (ignored unless canDrag): isDragging = true, mouseHasMoved = false, record startX/startY = e.clientX/Y, set container.style.cursor = "grabbing".
  • mousemove (only while dragging and canDrag): dx/dy = movement since the previous event; if |dx| > 5 || |dy| > 5 set mouseHasMoved = true (this later suppresses the click-to-expand). Compute dt = max(10, now - lastDragTime) in ms and per-ms velocities dragVelocityX = dx/dt, dragVelocityY = dy/dt. Then add the deltas to the target: targetX += dx; targetY += dy; and reset startX/startY to the current pointer.
  • mouseup: stop dragging, restore cursor:"grab"; momentum — if |dragVelocityX| > 0.1 || |dragVelocityY| > 0.1, throw the canvas: targetX += dragVelocityX * 200; targetY += dragVelocityY * 200; (momentumFactor = 200). The 0.075 lerp turns that offset into a smooth glide-out.

Touch drag: touchstart on the container and touchmove/touchend on window mirror the mouse logic (same 5px mouseHasMoved threshold, targetX/Y += dx/dy), but no momentum is applied on touchend.

2) Virtualized infinite grid — updateVisibleItems()

The grid is conceptually infinite in all four directions; only tiles near the viewport exist in the DOM.

  • buffer = 2.5; viewWidth = innerWidth * 3.5, viewHeight = innerHeight * 3.5.
  • Determine travel direction: movingRight = targetX > currentX, movingDown = targetY > currentY; direction buffers directionBufferX = movingRight ? -300 : 300 (same for Y) extend the window toward where you're heading.
  • Column/row range (cell pitch = itemWidth + itemGap = 270 horizontally, itemHeight + itemGap = 310 vertically):

``js startCol = Math.floor((-currentX - viewWidth/2 + (movingRight ? directionBufferX : 0)) / (itemWidth + itemGap)); endCol = Math.ceil ((-currentX + viewWidth*1.5 + (!movingRight ? directionBufferX : 0)) / (itemWidth + itemGap)); // startRow/endRow identical with currentY, viewHeight, movingDown, itemHeight ``

  • For every (col,row) in range: id = "${col},${row}". Skip if already in visibleItems, or if it is the currently-expanded tile (activeItemId === id && isExpanded). Otherwise create <div class="item" id="col,row"> at left = col * (itemWidth+itemGap), top = row * (itemHeight+itemGap) (store dataset.col/row), containing an <img> whose image number is

``js const itemNum = (Math.abs(row * columns + col) % itemCount) + 1; // 1..20 img.src = /path/to/img${itemNum}.jpg; ` so the 20 images tile deterministically across the infinite plane. Attach a click listener that bails if mouseHasMoved || isDragging, else calls handleItemClick(item). Append to the canvas and register in visibleItems`.

  • Finally sweep visibleItems: remove any DOM item whose id fell out of the current range (or that is the active expanded tile) and delete it from the set.
3) Click → expand (the hero tween)

handleItemClick toggles: if already expanded → close; else expandItem(item):

  1. Flags: isExpanded = true, activeItem = item, activeItemId = item.id, canDrag = false, container.style.cursor = "auto".
  2. Parse the image number out of the tile's img.src (regex /img(\d+)\.jpg/); titleIndex = (imgNum - 1) % items.length → the matching title from items.js.
  3. Title setupsetAndAnimateTitle(title): if a previous SplitType instance exists, revert() it; set projectTitleElement.textContent = title; titleSplit = new SplitType(el, { types: "words" }); then gsap.set(titleSplit.words, { y: "100%" }) so every word starts fully below the 42px clip mask.
  4. Hide the source tile: item.style.visibility = "hidden", and stash originalPosition = { id, rect: item.getBoundingClientRect(), imgSrc }.
  5. overlay.classList.add("active") → the pale scrim fades in via its CSS opacity 0.3s ease transition and starts intercepting clicks.
  6. Build the clone: <div class="expanded-item"> sized 120 × 160 px with the same <img>, appended to <body>; clicking it closes.
  7. Fade the grid: every other .itemgsap.to(el, { opacity: 0, duration: 0.3, ease: "power2.out" }).
  8. Target size: targetWidth = window.innerWidth * 0.4, targetHeight = targetWidth * 1.2 (keeps the 3:4 portrait ratio, 40% of viewport width).
  9. gsap.delayedCall(0.5, animateTitleIn) — the title starts rising halfway through the expansion.
  10. The expansion tween (remember the clone is position:fixed centered with translate(-50%,-50%), so x/y are offsets from the viewport center):

``js gsap.fromTo(expandedItem, { width: itemWidth, height: itemHeight, x: rect.left + itemWidth/2 - window.innerWidth/2, y: rect.top + itemHeight/2 - window.innerHeight/2 }, { width: targetWidth, height: targetHeight, x: 0, y: 0, duration: 1, ease: "hop" }); ` i.e. it starts exactly over the clicked thumbnail (position + size) and lands dead-center at 40vw wide — one tween animating width, height, x, y together on the "hop"` CustomEase.

  • animateTitleIn(): gsap.to(titleSplit.words, { y: "0%", duration: 1, stagger: 0.1, ease: "power3.out" }) — words rise out of the clip mask left-to-right, 0.1s apart.
4) Close → collapse back

closeExpandedItem() (fired by clicking the expanded image or the overlay):

  1. animateTitleOut(): gsap.to(titleSplit.words, { y: "-100%", duration: 1, stagger: 0.1, ease: "power3.out" }) — words exit upward through the mask (in from below, out through the top).
  2. overlay.classList.remove("active") (CSS fades the scrim out over 0.3s).
  3. Restore the grid: every .item except the active one → gsap.to(el, { opacity: 1, duration: 0.5, delay: 0.5, ease: "power2.out" }) — they wait half a second, then fade back while the clone shrinks.
  4. Reverse tween on the clone, back to the stored original rect:

``js gsap.to(expandedItem, { width: itemWidth, height: itemHeight, x: originalRect.left + itemWidth/2 - window.innerWidth/2, y: originalRect.top + itemHeight/2 - window.innerHeight/2, duration: 1, ease: "hop", onComplete: () => { /* cleanup */ } }); ` In onComplete: remove the clone from the DOM, set the original tile (looked up by activeItemId) back to visibility:"visible", null out expandedItem/activeItem/originalPosition/activeItemId, isExpanded = false, canDrag = true, container.style.cursor = "grab"`, and zero both drag velocities.

5) Resize

On window.resize: if currently expanded, re-fit the clone — gsap.to(expandedItem, { width: innerWidth*0.4, height: innerWidth*0.4*1.2, duration: 0.3, ease: "power2.out" }); otherwise just call updateVisibleItems().

6) Boot

Call updateVisibleItems() once, then start animate().

Assets / images

20 portrait images (3:4 aspect, e.g. 480×640+), named img1.jpgimg20.jpg, tiled endlessly across the grid. They share one art direction: abstract glossy 3D renders on black backgrounds — iridescent chrome and holographic materials (stacked ribbon loops, twisted mobius waves, torus rings, coiled spring helixes, chrome chain links and a padlock, intersecting discs and cones, bundles of tubes, spiral drip forms, stylized reaching hands, an anatomical heart with an orbiting ring). Palette: oil-slick purples, magentas, oranges, golds and greens against deep black — the black tiles pop hard against the #e3e3db page and drive the mix-blend-mode:difference UI. Each image i pairs with title i in items.js. No text, no logos in the images.

Behavior notes

  • The page never scrolls; the entire experience is the drag plane. Cursor: grabgrabbing while dragging → auto while expanded.
  • Works with mouse and touch; momentum-throw applies to mouse only.
  • The 5px mouseHasMoved threshold guarantees a drag never accidentally triggers an expand.
  • While expanded, dragging is fully disabled (canDrag=false) and the virtualization loop pauses (it runs inside the canDrag branch).
  • Item ids are "col,row" strings; virtualization keeps DOM size roughly constant no matter how far you pan.
  • Nav ("Motionprompts", About/Contact, FB/IG/YT) and footer ("Experiment 445" / "Drag to Explore") are static chrome in white, inverted by mix-blend-mode:difference.