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.
Images
This component ships with 20 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/infinite-draggable-image-gallery-javascript/img1.jpg
https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/img10.jpg
https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/img11.jpg
https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/img12.jpg
https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/img13.jpg
https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/img14.jpg
… 14 more under https://motionprompts.dev/c/infinite-draggable-image-gallery-javascript/
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—--plaster,--ink,--chrome-text. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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
*,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 only bothers to structure its own teardown because this catalogue's own editor demands it — mount(config) returns a destroy() purely so a knob change (item size, gap, drag ease, momentum) can re-mount the gallery cleanly, not because a plain shipped page ever calls it. React needs that same discipline, but it needs to live in useEffect's cleanup instead of a hand-rolled harness, and it needs to run even when no editor is watching.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component is unusually exposed to that: it wires eight listeners (mousedown/touchstart on the container, mousemove/mouseup/touchmove/touchend/resize on the window, click on the overlay) and starts its own requestAnimationFrame loop before the user does anything. Run the setup twice without tearing the first copy down and you get two live animate() loops, each with its own currentX/currentY closure, both writing canvas.style.transform on every frame — and two live mousemove listeners, each adding the same pointer delta to its own targetX/targetY. The visible result is not a crash: the grid pans at roughly double the speed the cursor actually moved, and the transform flickers between two slightly different values a frame apart. It will not reproduce 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 bottom of the script checks document.readyState before subscribing boot to DOMContentLoaded; that guard exists so the module survives being evaluated after the DOM has already parsed, something useEffect already guarantees by running post-commit. Drop the guard, the listener, and the window.MP && window.MP.register branch above it — that branch exists only so this catalogue's visual editor can re-invoke mount with a different config when a knob (itemWidth, itemHeight, itemGap, columns, dragEase, momentum) moves, and it has no equivalent in a shipped app. What survives is the body of mount(config) itself: the virtualization pass, the drag/rAF wiring, expandItem/closeExpandedItem, resize handling. Put that body inside a useEffect with an empty dependency array, and turn config into props (or a values object) read inside that same effect. If you do expose those six knobs as live props, keep bufferFor()'s ceiling on how many .item cells a config change is allowed to request — it exists specifically because a small itemWidth paired with a small itemGap inflates the visible-cell count by an order of magnitude, and a React version that re-runs the effect on every prop tick has exactly the same runaway-DOM-node risk this catalogue's own slider-driven editor already ran into.
*(2) Element lookups* — The four lookups at the top of mount (.container, #canvas, #overlay, .project-title p) assume this component owns the whole document; give the outer wrapper a root ref and resolve all four from it. The canvas.querySelectorAll(".item") calls inside expandItem/closeExpandedItem are already scoped through that same canvas reference, so they need no change. #canvas's children, though, are not React's to touch at all: updateVisibleItems() creates and destroys every .item node itself, outside any render. Give it an empty <div ref={canvasRef} id="canvas" /> in JSX and never place React-rendered content inside it — React has no record of the tiles this effect appends, and a render that reconciled into that subtree would collide with nodes it doesn't know exist. One lookup breaks the ref-scoping pattern on purpose: expandItem does document.body.appendChild(expandedItem), putting the cloned tile outside the component's own subtree entirely, as a sibling of your whole React root — deliberate, since the clone has to sit above the nav/footer/title in stacking order and be positioned against the viewport, not against the gallery. Keep a plain variable for that node and make sure both closeExpandedItem's completion callback and the effect's cleanup can remove it; a StrictMode unmount that lands mid-expansion is exactly the case where only the cleanup path runs.
*(3) Cleanup* — Wrap the setup in a gsap.context scoped to the root ref and revert it in the cleanup:
useEffect(() => {
const ctx = gsap.context((self) => {
/* updateVisibleItems, the drag/rAF wiring, expandItem/closeExpandedItem, resize handling */
}, rootRef);
return () => ctx.revert();
}, []);
gsap.registerPlugin(CustomEase) and the CustomEase.create("hop", …) call stay at module scope exactly as they are now — one-time global registrations, not per-mount setup, and re-running them on every effect is harmless but pointless.
This component's entire motion budget — the per-tile opacity fade in and out, the hop-eased expand/collapse tween on the cloned tile, the title's word-in and word-out tweens, the delayed call that kicks the title reveal off partway through the expansion — lives inside expandItem, closeExpandedItem, animateTitleIn and animateTitleOut, and every one of those only ever runs later, from a click listener, never while the effect's setup function itself is executing. gsap.context only auto-attributes animations created synchronously during that initial call; a tween built inside a click handler that fires long after mount is invisible to it unless the handler is registered through self.add().
self.add has two overloads and only one of them does what you want here. Always pass a name string first:
// CORRECT — two arguments. Registers the wrapper and returns it; nothing runs yet.
const expand = self.add("expand", (item) => {
/* fade the other tiles, the fromTo hop tween on the clone, setAndAnimateTitle */
});
item.addEventListener("click", () => {
if (!mouseHasMoved && !isDragging) expand(item);
});
// WRONG — one argument. This is not "register for later", it is "run now, inside the context".
const expand = self.add((item) => { … });
The one-argument form is GSAP's *immediate* overload: it invokes your function on the spot, during the effect's setup pass, passing the gsap.context object itself as the first argument, and it returns whatever your function returned rather than a callable wrapper. Written that way, expand runs before a single tile has ever been clicked, its item parameter holds a Context instance instead of a .item element, and the first line that reaches into it — the item.querySelector("img") that reads the image number — throws item.querySelector is not a function, synchronously, inside useEffect. React unmounts the whole tree; the gallery never appears. (The same call also leaves expand bound to undefined, so the click listener would fail a second time even if the body had survived.) The named form takes the identical function body and changes nothing about it — only when it runs.
Do the same for collapse and for animateTitleIn/animateTitleOut — each gets its own name string — and pass the returned wrappers, not the original functions, into gsap.delayedCall: the delayed call's own eventual firing is exactly the deferred invocation self.add exists to cover, and passing the unwrapped function silently drops that tween back outside the context. Skip the whole mechanism and ctx.revert() will still run cleanly (there is nothing for it to catch — the setup itself creates zero tweens synchronously, everything is click-driven), while a tween mid-flight from a click made just before a StrictMode unmount keeps animating into a closure that no longer has a live gallery under it.
One consequence of registering these as named context methods: they are now reachable as self.expand(…), and the wrapper re-enters the context on every call, so a collapse fired from the overlay long after mount still records its tweens where ctx.revert() can find them. That is the point. What it does *not* buy you is null-safety — collapse still has to bail when there is nothing expanded, and animateTitleIn/animateTitleOut still have to bail when titleSplit is null, because both can be invoked after the cleanup has already torn their targets down.
Three things ctx.revert() still does not know about, that the vanilla destroy() handles by hand and the port still has to:
- The eight listeners.
gsap.contexthas no visibility into plainaddEventListenercalls. Keep the named handler references and remove all eight in the cleanup, same as the vanilla script does. The per-tileclicklistenersupdateVisibleItems()attaches need no separate removal — each dies with the.itemnode it's attached to. - The tiles themselves. Track them the way the vanilla script does, in a
Mapfrom cell id to node, and delete that mount's own nodes from the canvas in the cleanup. Do not assume unmounting disposes of them: a StrictMode double-mount tears the *effect* down and builds it again against the same DOM, so the canvas still holds every tile the first pass appended while the second pass starts from an emptyvisibleItemsand appends a duplicate of each — same ids, twice the nodes, twice the image requests, and a virtualization sweep that only ever removes half of them. On a real unmount React does take the canvas subtree with it, which is exactly why this one hides in development and never shows up in a production build. - The rAF loop. Keep the handle
animate()'srequestAnimationFramecall returns and cancel it in the cleanup, or the lerp loop keeps writing tocanvas.style.transformforever. - The title's
SplitTypeinstance.setAndAnimateTitlealready reverts the *previous* split before creating the next one on every click, but that guard only fires on the next click — it does nothing for whatever split is live at the moment of unmount. If the gallery unmounts while a title is mid-expansion, the cleanup itself must kill the tweens ontitleSplit.wordsand then revert the split, in that order — a tween still targeting a.wordspan the revert has already removed throws — and reset the title paragraph's text back to empty, matching what the vanilla script's captured initial text restores.
And the body-level clone from *(2)*: if it still exists when the cleanup runs, kill its tweens and remove it from document.body there too — closeExpandedItem's own completion callback already guards against running once the component is torn down, so the two removal paths never race each other.
None of this component's state belongs in useState: isDragging, canDrag, isExpanded, mouseHasMoved, targetX/targetY, currentX/currentY, the visibleItems map, activeItemId, originalPosition. Every one of them is read and written only inside closures wired up in this same effect, drives nothing but direct DOM writes and GSAP calls, and never needs to cause a re-render. Promoting any of them to component state would make React re-render a subtree this effect already owns and mutates by hand, fighting the drag loop and the virtualization pass on every frame it runs.