L5

·

Drag · wrap · Flip

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.

Open the live demo →

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.

01

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.

02

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.

03

Building it with the Interactions panel

no code, free plan

What 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.

04

Custom attributes

Element settings → Custom attributes

Every tile image | draggable | false | Native image drag beats Draggable to the gesture. Same bug as the inertia slider.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- 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>

07

Gotchas that will cost you an hour

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. touch-action: none on the stage. Without it, mobile browsers claim the gesture for page scrolling and Draggable never receives a move event.
  6. A drag that ends over a tile fires a click. Track a moved flag in onDrag or every throw opens a random tile.
  7. 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.
08

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