← Gestures

Sliding Tile Puzzle

A picture cut into tiles with one missing. Drag or tap a tile next to the gap to slide it in, or tap one further along to push the whole row. Put them all back and the missing piece drops in to finish the picture. Leave the demo alone and it solves itself, a slide at a time.

Tap or drag a tile next to the gap. Leave it and it solves itself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sliding-tile-puzzle to an empty element with a width. It makes itself square.
  3. Point data-image at the picture.

Options

Add any of these attributes to the same element.

data-imageThe picture to cut up.
data-gridTiles per side. Default 3.
data-numbersShows each tile’s number in its corner, which makes it easier.
data-shuffleHow many random slides to mix it up with. Default 12 per tile, at most 120.
data-loopMixes itself up again a moment after it’s solved.
data-autoplaySolves itself one slide every so many ms while in view. Default 650. A real move pauses it.

It’s mixed up with legal slides, so it can always be solved. With focus, the arrow keys slide a tile into the gap. It fires solved when the picture is whole.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sliding Tile Puzzle: a picture cut into tiles with one missing. Drag or
    // tap a tile next to the gap to slide it in (tap one further along the
    // row or column to push the whole run). Put every tile back and the
    // missing piece drops in to finish the picture.
    // Usage: <div data-sliding-tile-puzzle data-image="photo.jpg" style="width: 300px"></div>
    // Optional: data-grid (tiles per side, default 3), data-numbers (shows
    // each tile's number in its corner), data-shuffle (random moves to mix it
    // up with, default 12 per tile, at most 120), data-loop (mixes itself
    // up again after it's solved), data-autoplay (ms between moves: it solves
    // itself one slide at a time while in view, default 650; a real move pauses it).
    // Keyboard: focus it and use the arrow keys to slide a tile into the gap.
    // Events: "solved" once the picture is whole.
    var DEFAULTS = { grid: 3, autoplay: 650 };

    var css =
      '.stp{position:relative;aspect-ratio:1;border-radius:14px;overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none;' +
      'background:rgba(0,0,0,.08);outline-offset:4px}' +
      '.stp-tile{position:absolute;left:0;top:0;border-radius:4px;background-repeat:no-repeat;cursor:pointer;will-change:transform;' +
      'box-shadow:inset 0 0 0 1px rgba(255,255,255,.65),inset 0 -3px 0 rgba(0,0,0,.12)}' +
      '.stp-tile.stp-stuck{cursor:default}' +
      '.stp-num{position:absolute;left:6%;top:6%;min-width:1.5em;padding:1px 4px;border-radius:99px;background:rgba(255,255,255,.85);color:#2b1748;' +
      'font:700 11px/1.3 system-ui,sans-serif;text-align:center;transition:opacity .4s}' +
      '.stp-done .stp-num{opacity:0}' +
      '.stp-done .stp-tile{box-shadow:none;border-radius:0;transition:box-shadow .5s,border-radius .5s}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var n = Math.max(2, Math.min(6, +d.grid || DEFAULTS.grid));
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('stp');
      if (!thumb) {
        el.tabIndex = 0;
        el.setAttribute('role', 'group');
        if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Sliding puzzle: use the arrow keys to slide tiles');
      }
      var img = d.image ? 'url("' + d.image + '")' : 'linear-gradient(135deg,#c4b5fd,#6a0dad)';
      var unit = 100 / n;

      var tiles = [];     // tiles[t]: the tile whose home is position t (the last one is the gap's)
      var at = [];        // at[pos]: tile index at that position, -1 for the gap
      for (var t = 0; t < n * n; t++) {
        var tile = document.createElement('div');
        tile.className = 'stp-tile';
        tile.setAttribute('aria-hidden', 'true');
        tile.style.width = tile.style.height = unit + '%';
        tile.style.backgroundImage = img;
        tile.style.backgroundSize = (n * 100) + '% ' + (n * 100) + '%';
        tile.style.backgroundPosition = (t % n) / (n - 1) * 100 + '% ' + Math.floor(t / n) / (n - 1) * 100 + '%';
        if (d.numbers !== undefined) {
          var b = document.createElement('span');
          b.className = 'stp-num';
          b.textContent = t + 1;
          tile.appendChild(b);
        }
        tile._stpT = t;
        el.appendChild(tile);
        tiles.push({ el: tile, pos: t });
        at.push(t);
      }
      var last = n * n - 1;
      at[last] = -1;
      var gap = last;
      var history = [];
      var s = el._stp = { userAt: -1e9, solved: false, busy: 0 };

      function place(t, col, row) {
        tiles[t].el.style.transform = 'translate(' + (col * 100).toFixed(2) + '%,' + (row * 100).toFixed(2) + '%)';
      }
      function snap(t) { place(t, tiles[t].pos % n, Math.floor(tiles[t].pos / n)); }

      function adjacent(p, q) {
        return (Math.abs(p - q) === n) || (Math.abs(p - q) === 1 && Math.floor(p / n) === Math.floor(q / n));
      }

      // Slide the tile at position p into the gap (they must be neighbours).
      function slide(p, ms, ease, record) {
        var t = at[p];
        if (t < 0 || !adjacent(p, gap)) return false;
        if (record !== false) {
          if (history.length && history[history.length - 1] === p) history.pop();
          else history.push(gap);
        }
        var from = { c: p % n, r: Math.floor(p / n) }, to = { c: gap % n, r: Math.floor(gap / n) };
        at[gap] = t; at[p] = -1;
        tiles[t].pos = gap;
        gap = p;
        if (tiles[t].anim) { tiles[t].anim.pause(); s.busy--; }
        var o = tiles[t].from || { c: from.c, r: from.r };
        tiles[t].from = null;
        s.busy++;
        tiles[t].anim = anime.animate(o, {
          c: to.c, r: to.r,
          duration: reduced() ? 0 : ms,
          ease: ease || 'out(3)',
          onUpdate: function () { place(t, o.c, o.r); },
          onComplete: function () { tiles[t].anim = null; s.busy--; check(); },
        });
        return true;
      }

      // Tap a tile in line with the gap to push the whole run towards it.
      function push(p) {
        var pr = Math.floor(p / n), gr = Math.floor(gap / n), pc = p % n, gc = gap % n;
        if (pr !== gr && pc !== gc) return false;
        var stepBy = pr === gr ? (pc < gc ? -1 : 1) : (pr < gr ? -n : n);
        var moved = false;
        while (gap !== p) {
          if (!slide(gap + stepBy, 200, 'out(3)')) break;
          moved = true;
        }
        return moved;
      }

      function check() {
        if (s.busy || s.solved) return;
        for (var i = 0; i < last; i++) if (at[i] !== i) return;
        s.solved = true;
        history = [];
        el.classList.add('stp-done');
        var lastTile = tiles[last].el;
        snap(last);
        lastTile.style.visibility = '';
        anime.animate(lastTile, { opacity: [0, 1], scale: [0.6, 1], duration: reduced() ? 0 : 500, ease: 'outBack(2)' });
        if (!reduced()) {
          anime.animate(tiles.map(function (o) { return o.el; }), {
            filter: ['brightness(1)', 'brightness(1.25)', 'brightness(1)'],
            duration: 700,
            delay: anime.stagger(50, { grid: [n, n], from: 'last' }),
            ease: 'inOut(2)',
          });
        }
        el.dispatchEvent(new CustomEvent('solved'));
        if (d.loop !== undefined && !reduced()) setTimeout(function () { mix(true); }, 2200);
      }

      // Mix it up with random moves, so it can always be solved.
      function mix(animated) {
        s.solved = false;
        el.classList.remove('stp-done');
        var count = Math.min(120, +d.shuffle || n * n * 12);
        if (animated) count = Math.min(count, n * n * 3);
        var lastTile = tiles[last].el;
        var hide = function () { lastTile.style.visibility = 'hidden'; };
        if (animated) anime.animate(lastTile, { opacity: 0, scale: 0.6, duration: 260, ease: 'in(2)', onComplete: hide });
        else hide();
        var prev = -1, moves = [];
        var g = gap;
        for (var i = 0; i < count; i++) {
          var opts = [g - n, g + n, g - 1, g + 1].filter(function (q) { return q >= 0 && q < n * n && q !== prev && adjacent(q, g); });
          var q = opts[Math.floor(Math.random() * opts.length)];
          moves.push(q);
          prev = g; g = q;
        }
        if (!animated) {
          moves.forEach(function (q) {
            var t = at[q];
            if (history.length && history[history.length - 1] === q) history.pop(); else history.push(gap);
            at[gap] = t; at[q] = -1; tiles[t].pos = gap; gap = q;
          });
          tiles.forEach(function (o, t) { snap(t); });
          return;
        }
        var k = 0;
        (function next() {
          if (k >= moves.length) return;
          slide(moves[k++], 70, 'linear');
          setTimeout(next, 85);
        })();
      }

      tiles.forEach(function (o, t) { snap(t); });
      tiles[last].el.style.visibility = 'hidden';
      if (reduced() && d.autoplay !== undefined) { tiles[last].el.style.visibility = ''; s.solved = true; el.classList.add('stp-done'); }
      else mix(false);

      function user() { s.userAt = performance.now(); }

      if (!thumb) {
        var drag = null;
        el.addEventListener('pointerdown', function (e) {
          var tile = e.target.closest('.stp-tile');
          if (e.button || !tile || s.solved) return;
          var p = tiles[tile._stpT].pos;
          user();
          e.preventDefault();
          el.setPointerCapture(e.pointerId);
          drag = { id: e.pointerId, p: p, t: tile._stpT, x: e.clientX, y: e.clientY, off: 0, can: adjacent(p, gap) && !s.busy };
          if (drag.can) { drag.dc = gap % n - p % n; drag.dr = Math.floor(gap / n) - Math.floor(p / n); }
        });
        el.addEventListener('pointermove', function (e) {
          if (!drag || e.pointerId !== drag.id || !drag.can) return;
          user();
          var size = el.clientWidth / n;
          var along = ((e.clientX - drag.x) * drag.dc + (e.clientY - drag.y) * drag.dr) / size;
          drag.off = Math.max(0, Math.min(1, along));
          var c = drag.p % n + drag.dc * drag.off, r = Math.floor(drag.p / n) + drag.dr * drag.off;
          place(drag.t, c, r);
          drag.moved = drag.moved || Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 6;
        });
        function end(e) {
          if (!drag || e.pointerId !== drag.id) return;
          var g = drag;
          drag = null;
          if (!g.moved) {
            if (g.can) snap(g.t);
            if (!s.busy) push(g.p);
            return;
          }
          if (!g.can) return;
          var c = g.p % n + g.dc * g.off, r = Math.floor(g.p / n) + g.dr * g.off;
          if (g.off > 0.4) {
            tiles[g.t].from = { c: c, r: r };
            slide(g.p, 160, 'out(2)');
          } else {
            var o = { c: c, r: r };
            anime.animate(o, { c: g.p % n, r: Math.floor(g.p / n), duration: 260, ease: 'outBack(1.5)', onUpdate: function () { place(g.t, o.c, o.r); } });
          }
        }
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
        el.addEventListener('keydown', function (e) {
          var map = { ArrowLeft: 1, ArrowRight: -1, ArrowUp: n, ArrowDown: -n };
          var dp = map[e.key];
          if (!dp || s.solved) return;
          e.preventDefault();
          user();
          var p = gap + dp;
          if (p >= 0 && p < n * n && adjacent(p, gap)) slide(p, 180);
        });
      }

      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var visible = false, timer = 0;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 900);
      }).observe(el);

      // Undo the moves so far, one at a time: that always ends solved.
      function play() {
        if (!visible) return;
        if (performance.now() - s.userAt < 5000 || s.busy || s.solved || !history.length) { timer = setTimeout(play, s.solved ? 900 : every); return; }
        slide(history[history.length - 1], Math.min(320, every * 0.55), 'inOut(2)');
        timer = setTimeout(play, every);
      }
    }

    function init() {
      document.querySelectorAll('[data-sliding-tile-puzzle]').forEach(function (el) {
        if (el._stp) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sliding-tile-puzzle data-image="photo.jpg" data-numbers style="width: 300px"></div>