Infinite draggable canvas
Nothing here is cloned, created or destroyed. There are forty-five tiles and there will only ever be forty-five tiles — gsap.utils.wrap() maps each tile's position back into a fixed range on both axes, so a tile that leaves the right edge is already the tile arriving on the left. The second idea is that the tiles have no real position: a single offset object is the only state, and drag, inertia and wheel all write to it. Add a source of truth per input and you will spend a day chasing a field that drifts. The third is knowing when to stop: while a tile is open, the render loop is frozen, because Flip and a per-frame transform loop cannot both own the same element.
How to build
Script only
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
Draggable · InertiaPlugin · Observer · Flip · utils.wrap
Build time
3–4 hours honestly. Budget a day the first time.
No-code?
No, and it is not close. This is an interaction model, not an animation.
Webflow structure
element · class · attribute<b>Div</b> <i>canvas_stage</i> ← position relative, overflow hidden, height 100svh, cursor grab, touch-action: none <b>Div</b> <i>canvas_tile</i> × cols·rows ← absolute, top 0 left 0, fixed w/h <b>Div</b> <i>canvas_tile-frame</i> ← overflow hidden, the Flip target <b>Image</b> <u>draggable="false"</u> <b>Text</b> <i>canvas_tile-cap</i> <b>Div</b> <i>canvas_hud</i> <b>Div</b> <i>canvas_scrim</i> ← fixed, z 250, opacity 0 <b>Div</b> <i>canvas_overlay</i> ← EMPTY. No transform. The open tile is moved in here so its position:fixed resolves against the viewport. <b>Div</b> <i>canvas_meta</i> ← fixed, z 270, opacity 0 <i>Grid rule:</i> cols × tileW must exceed (widest viewport + tileW). Same for rows. Fall short and the wrap leaves a visible gap at the edge.
Styles to set in the Designer
canvas_stage | <code>overflow: hidden</code>, <code>cursor: grab</code>, and <code>touch-action: none</code> — without that last one a mobile browser claims the gesture before Draggable ever sees it. canvas_tile | <code>position: absolute; top: 0; left: 0;</code> with a fixed width and height. Every tile starts at the origin; the script's transform is its only position. canvas_tile | Put the gutter on the tile's <code>padding</code>, not on a grid gap. The wrap maths works in whole tile units, so the gap has to live inside the tile. canvas_tile-frame.is-open | <code>position: fixed; top: 8vh; left: 50%; width: min(60vw,620px); height: 76vh; transform: translateX(-50%); z-index: 260</code>. Flip covers the difference.
Building it with the Interactions panel
no code, free planWhat the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesEvery tile image | draggable | false | Native image drag beats Draggable to the gesture. Same bug as the inertia slider.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(Draggable, InertiaPlugin, Observer, Flip); const stage = document.querySelector(".canvas_stage"); const scrim = document.querySelector(".canvas_scrim"); const tiles = gsap.utils.toArray(".canvas_tile"); const COLS = 9, ROWS = 5, TW = 260, TH = 330; const totalW = COLS * TW, totalH = ROWS * TH; // THE trick. wrap() folds any number back into a range, so a tile leaving // the right edge IS the tile arriving on the left. Nothing is cloned. const wrapX = gsap.utils.wrap(-TW, totalW - TW); const wrapY = gsap.utils.wrap(-TH, totalH - TH); const base = tiles.map((_, i) => ({ x: (i % COLS) * TW, y: Math.floor(i / COLS) * TH })); const setX = tiles.map(t => gsap.quickSetter(t, "x", "px")); const setY = tiles.map(t => gsap.quickSetter(t, "y", "px")); // ONE piece of state. Drag, inertia and wheel all write here. const offset = { x: 0, y: 0 }; const centre = () => { offset.x = (stage.offsetWidth - totalW) / 2; offset.y = (stage.offsetHeight - totalH) / 2; }; centre(); let frozen = false; function render() { if (frozen) return; for (let i = 0; i < tiles.length; i++) { setX[i](wrapX(base[i].x + offset.x)); setY[i](wrapY(base[i].y + offset.y)); } } render(); // ── drag: an invisible proxy, never the tiles themselves ──────────── const proxy = document.createElement("div"); let px = 0, py = 0, moved = false; const drag = Draggable.create(proxy, { trigger: stage, // drag anywhere on the field type: "x,y", inertia: true, onPress() { gsap.killTweensOf(offset); px = this.x; py = this.y; moved = false; stage.classList.add("is-dragging"); }, onRelease(){ stage.classList.remove("is-dragging"); }, onDrag() { moved = true; step(this); }, onThrowUpdate(){ step(this); } })[0]; function step(d) { offset.x += d.x - px; offset.y += d.y - py; px = d.x; py = d.y; render(); } // ── wheel / trackpad: same offset, so the two inputs cannot disagree ─ Observer.create({ target: stage, type: "wheel", preventDefault: true, onChange: self => gsap.to(offset, { x: offset.x - self.deltaX * 1.6, y: offset.y - self.deltaY * 1.6, duration: 0.9, ease: "power3.out", overwrite: true, onUpdate: render }) }); // ── click to zoom — the Flip gallery's pattern, plus a freeze ────────── const overlay = document.querySelector(".canvas_overlay"); let openFrame = null, openHome = null; function close() { if (!openFrame) return; const frame = openFrame, home = openHome; openFrame = openHome = null; gsap.to(scrim, { opacity: 0, duration: 0.35, pointerEvents: "none" }); const state = Flip.getState(frame); frame.classList.remove("is-open"); home.appendChild(frame); // back into its tile Flip.from(state, { duration: 0.6, ease: "power3.inOut", absolute: true, onComplete: () => { frozen = false; drag.enable(); render(); } }); } tiles.forEach(tile => tile.addEventListener("click", () => { if (moved) return; // a drag that ends on a tile is not a click if (openFrame) return close(); // Freeze FIRST. Flip and a per-frame transform loop cannot both own // the same element — one of them has to stand down. frozen = true; drag.disable(); const frame = tile.querySelector(".canvas_tile-frame"); openFrame = frame; openHome = tile; const state = Flip.getState(frame); // Every tile carries a transform, and a transformed ancestor becomes the // containing block for position:fixed — so "fixed" would resolve against // the tile, not the viewport. Move the node out. Animating a DOM move is // the entire reason Flip exists. overlay.appendChild(frame); frame.classList.add("is-open"); Flip.from(state, { duration: 0.7, ease: "power3.inOut", absolute: true }); gsap.to(scrim, { opacity: 1, duration: 0.45, pointerEvents: "auto" }); })); scrim.addEventListener("click", close); document.addEventListener("keydown", e => e.key === "Escape" && close()); addEventListener("resize", () => { centre(); render(); }); }); </script>
Gotchas that will cost you an hour
- A transformed ancestor breaks position: fixed. Each tile is transformed every frame, and any ancestor with a transform (or will-change: transform, or a filter) becomes the containing block for fixed children. The zoomed tile lands somewhere behind the scrim and looks like it never opened. Move the node into an untransformed container — Flip animates DOM moves natively, which is exactly what it is for.
- Make the grid bigger than the viewport plus one tile, on both axes. Nine columns of 260px covers 2340px. On a 2560px monitor you get a visible gap at the edge, and it will be the client's monitor.
- Freeze the loop before you Flip. Skip this and Flip's transform is overwritten by render() on the very next frame, so the tile snaps back instantly and appears not to open at all.
- One offset object, three inputs. The moment wheel and drag keep separate positions they disagree, and the field jumps whenever you switch input. Everything writes to offset.
- touch-action: none on the stage. Without it, mobile browsers claim the gesture for page scrolling and Draggable never receives a move event.
- A drag that ends over a tile fires a click. Track a moved flag in onDrag or every throw opens a random tile.
- quickSetter, not gsap.set. Forty-five tiles × two axes × 60fps is 5,400 property writes a second. quickSetter skips the whole tween pipeline; gsap.set does not, and you will feel it.
Push it further
- Feed drag velocity into a scale on the tiles — 1 → 0.94 while moving fast. It reads as the field having mass, and it is one quickTo.
- Blur tiles by distance from centre with a filter. Compute it inside render(); keep it to the outer ring or it costs frames.
- Add momentum-linked audio, or a subtle grain that intensifies with speed. This is the tier where those choices stop being gimmicks and start being art direction.
Webflow Motion Lab — GSAP 3.15, all plugins free.
28
/ 30