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.itemtiles 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 pinnedtop:0, footerbottom:0. Themix-blend-mode:differencemakes the white UI text invert over whatever passes beneath (dark thumbnails vs pale background)..links, .socials:display:flex; gap:2em;andnav > *, .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; GSAPx/yoffsets 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.wordclass 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 storelastX/lastY/lastUpdateTime. - Always re-queue
requestAnimationFrame(animate).
Mouse drag (listeners: mousedown on .container, mousemove/mouseup on window):
mousedown(ignored unlesscanDrag):isDragging = true,mouseHasMoved = false, recordstartX/startY = e.clientX/Y, setcontainer.style.cursor = "grabbing".mousemove(only while dragging andcanDrag):dx/dy= movement since the previous event; if|dx| > 5 || |dy| > 5setmouseHasMoved = true(this later suppresses the click-to-expand). Computedt = max(10, now - lastDragTime)in ms and per-ms velocitiesdragVelocityX = dx/dt,dragVelocityY = dy/dt. Then add the deltas to the target:targetX += dx; targetY += dy;and resetstartX/startYto the current pointer.mouseup: stop dragging, restorecursor:"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 buffersdirectionBufferX = 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 invisibleItems, or if it is the currently-expanded tile (activeItemId === id && isExpanded). Otherwise create<div class="item" id="col,row">atleft = col * (itemWidth+itemGap),top = row * (itemHeight+itemGap)(storedataset.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):
- Flags:
isExpanded = true,activeItem = item,activeItemId = item.id,canDrag = false,container.style.cursor = "auto". - Parse the image number out of the tile's
img.src(regex/img(\d+)\.jpg/);titleIndex = (imgNum - 1) % items.length→ the matching title fromitems.js. - Title setup —
setAndAnimateTitle(title): if a previousSplitTypeinstance exists,revert()it; setprojectTitleElement.textContent = title;titleSplit = new SplitType(el, { types: "words" }); thengsap.set(titleSplit.words, { y: "100%" })so every word starts fully below the 42px clip mask. - Hide the source tile:
item.style.visibility = "hidden", and stashoriginalPosition = { id, rect: item.getBoundingClientRect(), imgSrc }. overlay.classList.add("active")→ the pale scrim fades in via its CSSopacity 0.3s easetransition and starts intercepting clicks.- Build the clone:
<div class="expanded-item">sized120 × 160px with the same<img>, appended to<body>; clicking it closes. - Fade the grid: every other
.item→gsap.to(el, { opacity: 0, duration: 0.3, ease: "power2.out" }). - Target size:
targetWidth = window.innerWidth * 0.4,targetHeight = targetWidth * 1.2(keeps the 3:4 portrait ratio, 40% of viewport width). gsap.delayedCall(0.5, animateTitleIn)— the title starts rising halfway through the expansion.- The expansion tween (remember the clone is
position:fixedcentered withtranslate(-50%,-50%), sox/yare 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):
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).overlay.classList.remove("active")(CSS fades the scrim out over 0.3s).- Restore the grid: every
.itemexcept 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. - 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.jpg … img20.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:
grab→grabbingwhile dragging →autowhile expanded. - Works with mouse and touch; momentum-throw applies to mouse only.
- The 5px
mouseHasMovedthreshold guarantees a drag never accidentally triggers an expand. - While expanded, dragging is fully disabled (
canDrag=false) and the virtualization loop pauses (it runs inside thecanDragbranch). - 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.