← Cards & images

Infinite Canvas Gallery

An endless wall of pictures in staggered columns. Grab it and drag in any direction: it never runs out, glides on when you let go and drifts slowly by itself when left alone. The wall dips back a touch while you hold it.

Night Drive
Bloom
Tides
Neon

Drag in any direction

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-infinite-canvas-gallery to a box with a height, holding your pictures. They repeat across the wall forever, never next to themselves.

Options

Add any of these attributes to the same element.

data-sizeWidth of each tile. Default 170 px, and smaller on narrow screens.
data-ratioHeight of a tile as a share of its width. Default 1.25.
data-gapSpace between tiles. Default 14 px.
data-staggerHow far every other column is shifted down, as a share of a tile. Default 0.5.
data-driftSpeed it drifts at when left alone, in px a second. Default 18; 0 keeps it still.
data-resumeHow long after someone lets go before it drifts again. Default 2500 ms.

Only enough tiles to cover the box are made, and they’re recycled as they scroll off, so the wall stays light however far you go. Links in the tiles still work, but not at the end of a drag. The pictures themselves stay in the page once, hidden, for screen readers. With reduced motion it doesn’t drift or glide.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Infinite Canvas Gallery: an endless wall of pictures in staggered
    // columns that you can drag in any direction. Let go and it glides to a
    // stop; leave it alone and it drifts slowly by itself.
    // Usage: <div data-infinite-canvas-gallery style="height: 420px">
    //          <img src="1.jpg" alt="…"> <img src="2.jpg" alt="…"> …
    //        </div>
    // The children are the pictures; they repeat across the wall forever.
    // Optional: data-size (tile width in px, default 170; smaller on narrow
    // screens), data-ratio (tile height / width, default 1.25), data-gap
    // (px, default 14), data-stagger (how far alternate columns are shifted,
    // as a share of a tile, default 0.5), data-drift (px a second it drifts
    // when left alone, default 18; 0 to stay still), data-resume (ms after
    // someone lets go before it drifts again, default 2500).
    var DEFAULTS = { size: 170, ratio: 1.25, gap: 14, stagger: 0.5, drift: 18, resume: 2500 };

    var css =
      '.icg{position:relative;overflow:hidden;touch-action:none;cursor:grab;-webkit-user-select:none;user-select:none}' +
      '.icg.icg-drag{cursor:grabbing}' +
      '.icg-wall{position:absolute;inset:0}' +
      '.icg-tile{position:absolute;left:0;top:0;overflow:hidden;border-radius:10px;background:rgba(127,127,127,.15);box-shadow:0 6px 18px rgba(15,15,30,.16);will-change:transform}' +
      '.icg-tile>*{width:100%!important;height:100%!important;margin:0;object-fit:cover;pointer-events:none;-webkit-user-drag:none}' +
      '.icg-tile>img,.icg-tile>svg,.icg-tile>video{display:block}' +
      '.icg-src{position:absolute!important;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function mod(a, n) { return ((a % n) + n) % n; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var items = Array.prototype.slice.call(el.children);
      if (!items.length) return;
      el.classList.add('icg');
      // The originals stay in the page once, out of sight, for screen readers
      // and so any SVG gradients the copies point at still exist.
      var src = document.createElement('div');
      src.className = 'icg-src';
      items.forEach(function (n) { src.appendChild(n); });
      var wall = document.createElement('div');
      wall.className = 'icg-wall';
      wall.setAttribute('aria-hidden', 'true');
      el.appendChild(src);
      el.appendChild(wall);
      if (!el.closest('[aria-hidden="true"]')) {
        if (!el.hasAttribute('role')) el.setAttribute('role', 'region');
        if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Gallery');
      }

      var s = el._icgS = {
        items: items, wall: wall, tiles: [], ox: 0, oy: 0, vx: 0, vy: 0,
        drag: null, idleAt: 0, raf: 0, last: 0, visible: false
      };

      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible) kick(el);
      }).observe(el);

      var moved = 0;
      el.addEventListener('pointerdown', function (e) {
        if (e.button) return;
        el.setPointerCapture(e.pointerId);
        el.classList.add('icg-drag');
        s.drag = { x: e.clientX, y: e.clientY, t: performance.now() };
        s.vx = s.vy = 0;
        moved = 0;
        if (!reduced()) anime.animate(wall, { scale: 0.97, duration: 300, ease: 'out(3)' });
        kick(el);
      });
      el.addEventListener('pointermove', function (e) {
        if (!s.drag) return;
        var now = performance.now();
        var dx = e.clientX - s.drag.x, dy = e.clientY - s.drag.y;
        var dt = Math.max(1, now - s.drag.t);
        s.ox += dx; s.oy += dy;
        moved += Math.abs(dx) + Math.abs(dy);
        // Smoothed velocity in px per ms, for the glide after letting go.
        s.vx = s.vx * 0.6 + (dx / dt) * 0.4;
        s.vy = s.vy * 0.6 + (dy / dt) * 0.4;
        s.drag = { x: e.clientX, y: e.clientY, t: now };
        render(el);
      });
      function end() {
        if (!s.drag) return;
        if (performance.now() - s.drag.t > 80) s.vx = s.vy = 0;   // held still before letting go
        if (reduced()) s.vx = s.vy = 0;
        s.drag = null;
        s.idleAt = performance.now();
        el.classList.remove('icg-drag');
        anime.animate(wall, { scale: 1, duration: 500, ease: 'outElastic(1, .7)' });
        kick(el);
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
      // Links inside the tiles shouldn't fire at the end of a drag.
      el.addEventListener('click', function (e) { if (moved > 6) { e.preventDefault(); e.stopPropagation(); } }, true);
      el.addEventListener('dragstart', function (e) { e.preventDefault(); });
    }

    function layout(el) {
      var s = el._icgS, d = el.dataset;
      var W = el.clientWidth, H = el.clientHeight;
      if (!W || !H) return;
      var size = Math.min(num(d.size, DEFAULTS.size), Math.max(80, W / 2.8));
      var gap = num(d.gap, DEFAULTS.gap);
      s.tw = size;
      s.th = size * num(d.ratio, DEFAULTS.ratio);
      s.cw = s.tw + gap;
      s.ch = s.th + gap;
      s.shift = num(d.stagger, DEFAULTS.stagger) * s.ch;
      s.cols = Math.ceil(W / s.cw) + 2;
      s.rows = Math.ceil(H / s.ch) + 2;
      var need = s.cols * s.rows;
      while (s.tiles.length < need) {
        var t = document.createElement('div');
        t.className = 'icg-tile';
        s.wall.appendChild(t);
        s.tiles.push({ el: t, k: -1 });
      }
      while (s.tiles.length > need) s.wall.removeChild(s.tiles.pop().el);
      s.tiles.forEach(function (t) { t.el.style.width = s.tw + 'px'; t.el.style.height = s.th + 'px'; });
      // Start with the wall centred on a tile.
      if (!s.placed) { s.placed = true; s.ox = (W - s.tw) / 2; s.oy = (H - s.th) / 2 - s.ch * 0.3; }
      render(el);
    }

    function render(el) {
      var s = el._icgS;
      var n = s.items.length, spanX = s.cols * s.cw, spanY = s.rows * s.ch;
      for (var a = 0; a < s.cols; a++) {
        // Which column of the endless wall this slot shows right now.
        var x = mod(a * s.cw + s.ox, spanX) - s.cw;
        var i = Math.round((x - s.ox) / s.cw);
        var sh = mod(i, 2) ? s.shift : 0;
        for (var b = 0; b < s.rows; b++) {
          var y = mod(b * s.ch + s.oy + sh, spanY) - s.ch;
          var j = Math.round((y - s.oy - sh) / s.ch);
          var t = s.tiles[a * s.rows + b];
          // Neighbours never repeat: step 3 across and 5 down through the list.
          var k = mod(i * 3 + j * (n > 5 ? 5 : 1), n);
          if (t.k !== k) {
            t.k = k;
            var copy = s.items[k].cloneNode(true);
            if (copy.removeAttribute) copy.removeAttribute('id');
            if (copy.querySelectorAll) copy.querySelectorAll('[id]').forEach(function (m) { m.removeAttribute('id'); });
            t.el.textContent = '';
            t.el.appendChild(copy);
          }
          t.el.style.transform = 'translate3d(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px,0)';
        }
      }
    }

    function kick(el) {
      var s = el._icgS;
      if (s.raf || !s.visible) return;
      s.last = performance.now();
      s.raf = requestAnimationFrame(function tick(now) {
        s.raf = 0;
        if (!s.visible) return;
        var dt = Math.min(50, now - s.last);
        s.last = now;
        var d = el.dataset, busy = false;
        if (!s.drag) {
          if (Math.abs(s.vx) + Math.abs(s.vy) > 0.005) {
            s.ox += s.vx * dt; s.oy += s.vy * dt;
            var k = Math.pow(0.995, dt);   // glide to a stop
            s.vx *= k; s.vy *= k;
            busy = true;
          } else {
            s.vx = s.vy = 0;
            var drift = num(d.drift, DEFAULTS.drift);
            if (drift && !reduced() && now - s.idleAt > num(d.resume, DEFAULTS.resume)) {
              // Ease the drift in so it never starts with a jolt.
              s.ease = Math.min(1, (s.ease || 0) + dt / 1500);
              s.ox -= drift * 0.8 * s.ease * dt / 1000;
              s.oy -= drift * 0.6 * s.ease * dt / 1000;
              busy = true;
            } else s.ease = 0;
            if (drift && !reduced()) busy = true;   // keep watching for the resume time
          }
          render(el);
        }
        if (busy || s.drag) s.raf = requestAnimationFrame(tick);
      });
    }

    function init() {
      document.querySelectorAll('[data-infinite-canvas-gallery]').forEach(function (el) {
        if (el._icg) return;
        el._icg = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-infinite-canvas-gallery style="height: 420px">
  <img src="1.jpg" alt="Northern lights">
  <img src="2.jpg" alt="Mountain lake">
  <img src="3.jpg" alt="Dunes at dusk">
  <!-- … as many as you like -->
</div>