← Generative art

Sorting Visualiser

A row of shuffled bars sorts itself one comparison and swap at a time, with bubble sort, then quick sort, then merge sort. Bars being compared flash white, bars being moved flash amber, and bars that have reached their final place light up. Every algorithm runs at the same number of steps per second, so you can see quick sort and merge sort finish long before bubble sort. Pick one with the buttons.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sorting-visualiser to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-algorithmbubble, insertion, quick or merge, or a comma-separated list to take turns. Default bubble,quick,merge.
data-barsHow many bars. Default scaled to the width, 16 to 70.
data-speedSteps (comparisons, swaps and writes) per second. Default scaled to the number of bars.
data-colorsTwo colours the bars run between, short to tall. Default #818cf8,#f472b6.
data-controlsAdd buttons to pick an algorithm. Picking one sticks to it.

Each sort runs on a copy of the bars first and records every step, which is then played back. Quick sort takes the middle bar as its pivot; merge sort is the top-down kind. It’s paused when off screen, and with reduced motion it shows the bars already sorted.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sorting Visualiser: a row of shuffled bars sorts itself, one comparison
    // and swap at a time, with bubble, insertion, quick or merge sort. Bars
    // being compared flash white, bars being moved flash amber, and bars in
    // their final place light up. Every algorithm runs at the same number of
    // steps per second, so the quick ones really do finish sooner.
    // Usage: <div data-sorting-visualiser style="height:360px;background:#120a24"></div>
    // Optional: data-algorithm (bubble, insertion, quick or merge, or a
    // comma-separated list to take turns; default "bubble,quick,merge"),
    // data-bars (how many bars, default scaled to the width, 16 to 70),
    // data-speed (steps per second, default scaled to the number of bars),
    // data-colors (two colours the bars run between, short to tall, default
    // #818cf8,#f472b6), data-controls (add buttons to pick an algorithm).
    var DEFAULTS = { algorithm: 'bubble,quick,merge', colors: '#818cf8,#f472b6' };
    var NAMES = { bubble: 'Bubble sort', insertion: 'Insertion sort', quick: 'Quick sort', merge: 'Merge sort' };

    var css =
      '[data-sorting-visualiser]{position:relative;isolation:isolate;overflow:hidden}' +
      '.srv-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}' +
      '.srv-controls{position:absolute;top:10px;left:0;right:0;display:flex;flex-wrap:wrap;justify-content:center;gap:6px;padding:0 10px}' +
      '.srv-btn{font:500 12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.22);' +
      'background:rgba(255,255,255,.06);color:rgba(255,255,255,.85);cursor:pointer;transition:background .2s,color .2s,border-color .2s}' +
      '.srv-btn:hover{background:rgba(255,255,255,.14)}' +
      '.srv-btn[aria-pressed="true"]{background:#f472b6;border-color:#f472b6;color:#1a0b2e}' +
      '.srv-btn:focus-visible{outline:2px solid #fff;outline-offset:2px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function hex(c) {
      c = c.replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }

    // Each algorithm sorts a copy of the bars and records what it did:
    // ['c', i, j] compare, ['s', i, j] swap, ['w', i, value] write, ['d', i] in place.
    var SORTS = {
      bubble: function (a, ops) {
        var n = a.length;
        for (var i = 0; i < n - 1; i++) {
          var swapped = false;
          for (var j = 0; j < n - 1 - i; j++) {
            ops.push(['c', j, j + 1]);
            if (a[j] > a[j + 1]) { var t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; ops.push(['s', j, j + 1]); swapped = true; }
          }
          ops.push(['d', n - 1 - i]);
          if (!swapped) break;
        }
      },
      insertion: function (a, ops) {
        for (var i = 1; i < a.length; i++) {
          for (var j = i; j > 0; j--) {
            ops.push(['c', j - 1, j]);
            if (a[j - 1] <= a[j]) break;
            var t = a[j]; a[j] = a[j - 1]; a[j - 1] = t;
            ops.push(['s', j - 1, j]);
          }
        }
      },
      quick: function (a, ops) {
        function sort(lo, hi) {
          if (lo > hi) return;
          if (lo === hi) { ops.push(['d', lo]); return; }
          // Middle bar as the pivot, parked at the end while the rest is split round it.
          var m = (lo + hi) >> 1, t = a[m]; a[m] = a[hi]; a[hi] = t;
          if (m !== hi) ops.push(['s', m, hi]);
          var p = a[hi], i = lo;
          for (var j = lo; j < hi; j++) {
            ops.push(['c', j, hi]);
            if (a[j] < p) {
              if (i !== j) { t = a[i]; a[i] = a[j]; a[j] = t; ops.push(['s', i, j]); }
              i++;
            }
          }
          t = a[i]; a[i] = a[hi]; a[hi] = t;
          if (i !== hi) ops.push(['s', i, hi]);
          ops.push(['d', i]);
          sort(lo, i - 1);
          sort(i + 1, hi);
        }
        sort(0, a.length - 1);
      },
      merge: function (a, ops) {
        function sort(lo, hi) {
          if (hi <= lo) return;
          var mid = (lo + hi) >> 1;
          sort(lo, mid);
          sort(mid + 1, hi);
          var left = a.slice(lo, mid + 1), right = a.slice(mid + 1, hi + 1), i = 0, j = 0, k = lo;
          while (i < left.length && j < right.length) {
            ops.push(['c', lo + i, mid + 1 + j]);
            a[k] = left[i] <= right[j] ? left[i++] : right[j++];
            ops.push(['w', k, a[k]]);
            k++;
          }
          while (i < left.length) { a[k] = left[i++]; ops.push(['w', k, a[k]]); k++; }
          while (j < right.length) { a[k] = right[j++]; ops.push(['w', k, a[k]]); k++; }
        }
        sort(0, a.length - 1);
      }
    };

    function shuffled(n) {
      var a = [];
      for (var i = 1; i <= n; i++) a.push(i);
      for (i = n - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)), t = a[i]; a[i] = a[j]; a[j] = t; }
      return a;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'srv-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var algos = (d.algorithm || DEFAULTS.algorithm).split(',').map(function (s) { return s.trim(); }).filter(function (s) { return SORTS[s]; });
      if (!algos.length) algos = ['quick'];
      var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return hex(c.trim()); });
      if (cols.length < 2) cols.push(cols[0]);
      var still = reduced();
      var controls = null, buttons = {};
      var w = 0, h = 0, dpr = 1, n = 0, speed = 0, flashMs = 220;
      var bars = [], from = null, done = [], flash = [], ops = [], opAt = 0, compares = 0;
      var algoIdx = 0, picked = false, phase = 'sort', phaseT = 0, acc = 0, clock = 0;

      if (d.controls !== undefined && !el.closest('[aria-hidden="true"]')) {
        controls = document.createElement('div');
        controls.className = 'srv-controls';
        controls.setAttribute('role', 'group');
        controls.setAttribute('aria-label', 'Sorting algorithm');
        Object.keys(NAMES).forEach(function (key) {
          var b = document.createElement('button');
          b.type = 'button';
          b.className = 'srv-btn';
          b.textContent = NAMES[key].replace(' sort', '');
          b.setAttribute('aria-pressed', 'false');
          b.addEventListener('click', function () {
            picked = true;
            algos = [key];
            algoIdx = 0;
            start(true);
          });
          buttons[key] = b;
          controls.appendChild(b);
        });
        el.appendChild(controls);
      }

      function current() { return algos[algoIdx % algos.length]; }

      function start(reshuffle) {
        from = null;
        if (reshuffle) bars = shuffled(n);
        var copy = bars.slice();
        ops = [];
        SORTS[current()](copy, ops);
        opAt = 0; compares = 0; acc = 0;
        done = []; flash = [];
        for (var i = 0; i < n; i++) { done.push(false); flash.push([0, 0]); }
        phase = 'sort'; phaseT = 0;
        Object.keys(buttons).forEach(function (k) { buttons[k].setAttribute('aria-pressed', k === current() ? 'true' : 'false'); });
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        var nn = Math.max(4, Math.min(200, Math.round(+d.bars || Math.max(16, Math.min(70, w / 14)))));
        speed = +d.speed || Math.max(60, nn * nn / 12);
        // At high speeds a shorter flash keeps the moving bars from blurring together.
        flashMs = Math.max(50, Math.min(220, 6000 / speed));
        if (nn !== n || !bars.length) { n = nn; bars = shuffled(n); start(false); }
        return true;
      }

      function apply(op) {
        var t = clock;
        if (op[0] === 'c') { compares++; flash[op[1]] = [t, 1]; flash[op[2]] = [t, 1]; }
        else if (op[0] === 's') { var x = bars[op[1]]; bars[op[1]] = bars[op[2]]; bars[op[2]] = x; flash[op[1]] = [t, 2]; flash[op[2]] = [t, 2]; }
        else if (op[0] === 'w') { bars[op[1]] = op[2]; flash[op[1]] = [t, 2]; }
        else if (op[0] === 'd') done[op[1]] = true;
      }

      function draw() {
        ctx.clearRect(0, 0, w, h);
        var top = controls ? Math.max(52, controls.offsetHeight + 22) : 0;
        var small = w < 420 || h < 260;
        var fs = small ? 12 : 14;
        ctx.font = '600 ' + fs + 'px system-ui, sans-serif';
        ctx.textBaseline = 'middle';
        var labelY = top + (small ? 16 : 22);
        ctx.fillStyle = 'rgba(255,255,255,.92)';
        ctx.textAlign = 'left';
        ctx.fillText(NAMES[current()], 14, labelY);
        ctx.fillStyle = 'rgba(255,255,255,.55)';
        ctx.textAlign = 'right';
        ctx.font = '500 ' + fs + 'px system-ui, sans-serif';
        ctx.fillText(compares + ' comparisons', w - 14, labelY);
        var y0 = labelY + (small ? 16 : 22), y1 = h - 12;
        var side = 14, gap = n > 50 ? 2 : 3;
        var bw = (w - side * 2 - gap * (n - 1)) / n;
        for (var i = 0; i < n; i++) {
          var v = bars[i];
          if (from) v = from[i] + (bars[i] - from[i]) * phaseT;
          var f = (v - 1) / Math.max(1, n - 1), c0 = cols[0], c1 = cols[cols.length - 1];
          var rgb = [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
          // Recently touched bars flash white (compared) or amber (moved), then fade back.
          var fl = flash[i], age = (clock - fl[0]) / (fl[1] === 3 ? 220 : flashMs);
          if (fl[1] && age < 1) {
            var hot = fl[1] === 2 ? [251, 191, 36] : [255, 255, 255], k = 1 - age;
            rgb = [rgb[0] + (hot[0] - rgb[0]) * k, rgb[1] + (hot[1] - rgb[1]) * k, rgb[2] + (hot[2] - rgb[2]) * k];
          }
          ctx.globalAlpha = done[i] ? 1 : 0.6;
          ctx.fillStyle = 'rgb(' + Math.round(rgb[0]) + ',' + Math.round(rgb[1]) + ',' + Math.round(rgb[2]) + ')';
          var bh = (y1 - y0) * (0.06 + 0.94 * v / n);
          var x = side + i * (bw + gap);
          ctx.beginPath();
          if (ctx.roundRect) ctx.roundRect(x, y1 - bh, bw, bh, Math.min(3, bw / 2));
          else ctx.rect(x, y1 - bh, bw, bh);
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

      resize();
      if (still) {
        // The finished sort.
        bars.sort(function (a, b) { return a - b; });
        done = done.map(function () { return true; });
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () {
          if (resize()) { bars.sort(function (a, b) { return a - b; }); done = done.map(function () { return true; }); draw(); }
        }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(100, now - last) : 16;
          last = now;
          clock += dt;
          if (phase === 'sort') {
            acc += dt / 1000 * speed;
            while (acc >= 1 && opAt < ops.length) { apply(ops[opAt++]); acc -= 1; }
            if (opAt >= ops.length) { phase = 'sweep'; phaseT = 0; }
          } else if (phase === 'sweep') {
            // A quick run along the row, lighting each bar now it's in place.
            phaseT += dt / 600;
            var upto = Math.min(n, Math.floor(phaseT * n));
            for (var i = 0; i < upto; i++) if (!done[i] || flash[i][1] !== 3) { done[i] = true; flash[i] = [clock, 3]; }
            if (phaseT >= 1) { phase = 'hold'; phaseT = 0; }
          } else if (phase === 'hold') {
            phaseT += dt / 1300;
            if (phaseT >= 1) {
              // Bars slide to a new shuffle.
              from = bars.slice();
              bars = shuffled(n);
              done = done.map(function () { return false; });
              phase = 'shuffle'; phaseT = 0;
            }
          } else if (phase === 'shuffle') {
            phaseT = Math.min(1, phaseT + dt / 600);
            if (phaseT >= 1) {
              from = null;
              if (!picked) algoIdx = (algoIdx + 1) % algos.length;
              start(false);
            }
          }
          if (phase === 'shuffle') {
            var e = phaseT;
            phaseT = e * e * (3 - 2 * e);   // ease the slide
            draw();
            phaseT = e;
          } else draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-sorting-visualiser]').forEach(function (el) {
        if (el._srv) return;
        el._srv = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sorting-visualiser data-controls style="height: 360px; background: #120a24"></div>