Back to the library

The Patient Swap

A bubble sort slowed to human speed: five bars, one adjacent pair at a time. Each step brackets the two neighbours under consideration, dims everything else, writes the comparison out in full (4 > 1), states its verdict — swap or in order — and only then lets the pair cross, one arcing over while the other dips beneath so the exchange is never ambiguous. Motion is all transform and opacity on a strong ease-in-out (0.77, 0, 0.175, 1), deliberately slower than UI timing because the point is comprehension, not responsiveness; values that reach their final slot hollow out into olive outlines, and the run ends on the pass where nothing swaps. Reach for it when you need to teach an algorithm rather than decorate a page — an explainer, a docs hero, a CS lesson where the viewer must be able to say why each move happened.

conceptalgorithmsortingexplainersequencediagram

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Patient Swap — a sorting algorithm you can follow</title>
<style>
  /* ────────────────────────────────────────────────────────────────
     THE PATIENT SWAP
     A bubble sort slowed to human speed. Every step states its own
     reason: two neighbours are bracketed, the comparison is written
     out, and only then does the pair exchange places.

     WHEN PASTING INTO A REAL PROJECT keep everything between the
     "KEEP" markers: the .sortviz block of CSS and the .sortviz markup
     + its script. The page chrome (body centring, .card) is only here
     so the piece has somewhere to sit.
     ──────────────────────────────────────────────────────────────── */

  :root {
    --ivory:    #FAF9F5;
    --ink:      #141413;
    --clay:     #D97757;
    --oat:      #E3DACC;
    --olive:    #788C5D;
    --rust:     #B04A3F;
    --gray-500: #87867F;
    --gray-300: #D1CFC5;

    --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    --serif: ui-serif, Georgia, "Times New Roman", serif;

    --ease-out:    cubic-bezier(0.23, 1, 0.32, 1);
    --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    background: var(--ivory);
    color: var(--ink);
    font-family: var(--mono);
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: 24px;
    -webkit-font-smoothing: antialiased;
  }

  /* ── page chrome (not part of the piece) ───────────────────────── */
  .card {
    width: 100%;
    max-width: 660px;
    aspect-ratio: 4 / 3;
    background: var(--ivory);
    border: 1.5px solid var(--gray-300);
    border-radius: 14px;
    overflow: hidden;
  }

  /* ══ KEEP FROM HERE ═══════════════════════════════════════════════ */

  .sortviz {
    position: relative;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    padding: 26px clamp(16px, 4vw, 34px) 24px;
    font-family: var(--mono);
    color: var(--ink);
    user-select: none;
  }

  .sv-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    font-size: 10.5px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--gray-500);
  }
  .sv-head .pass {
    transition: opacity 260ms var(--ease-out);
  }

  /* ── stage ─────────────────────────────────────────────────────── */
  .sv-stage {
    flex: 1;
    display: grid;
    place-items: center;
    min-height: 0;
  }

  .sv-bars {
    position: relative;
    height: 100%;
    max-height: 300px;
    width: 356px;          /* 4 * pitch + bar width */
    transform-origin: 50% 100%;
    transform: scale(var(--fit, 1));
  }

  .sv-baseline {
    position: absolute;
    left: -18px; right: -18px; bottom: 0;
    height: 1.5px;
    background: var(--gray-300);
    transform: scaleX(0);
    transform-origin: 0 50%;
    transition: transform 720ms var(--ease-out);
  }
  .sv-stage.ready .sv-baseline { transform: scaleX(1); }

  .bar {
    position: absolute;
    left: 0; bottom: 0;
    width: 52px;
    transform: translateX(var(--x));
    /* stagger only bites on entrance / dissolve; zeroed while sorting */
    transition:
      transform 560ms var(--ease-in-out),
      opacity   400ms var(--ease-out);
    transition-delay: calc(var(--i) * 70ms);
    will-change: transform, opacity;
  }

  .hop { transform: translateY(0); }

  .body {
    position: relative;
    border: 1.5px solid var(--gray-300);
    border-bottom: none;
    border-radius: 5px 5px 0 0;
    background: var(--oat);
    transition:
      background-color 240ms var(--ease-out),
      border-color     240ms var(--ease-out),
      transform        260ms var(--ease-out);
  }

  .num {
    position: absolute;
    top: 8px; left: 0; right: 0;
    text-align: center;
    font-size: 12px;
    letter-spacing: 0.04em;
    color: var(--gray-500);
    transition: color 240ms var(--ease-out);
  }

  /* the two neighbours under consideration lift and brighten */
  .sv-stage.run .sv-bars.compare .bar { opacity: 0.42; }
  .sv-stage.run .sv-bars.compare .bar.active { opacity: 1; }
  .bar.active .body {
    background: #F0DCD3;             /* clay, washed out */
    border-color: var(--clay);
    transform: translateY(-10px);
  }
  .bar.active .num { color: var(--clay); }

  /* a value that has reached its final resting slot */
  .bar.settled .body { border-color: var(--olive); background: var(--ivory); }
  .bar.settled .num  { color: var(--olive); }

  /* crossing: one neighbour arcs over, the other dips beneath */
  .bar.over  { z-index: 3; }
  .bar.under { z-index: 1; }
  .bar.over  .hop { animation: sv-arc 560ms var(--ease-in-out); }
  .bar.under .hop { animation: sv-dip 560ms var(--ease-in-out); }

  @keyframes sv-arc {
    0%   { transform: translateY(0); }
    50%  { transform: translateY(-26px); }
    100% { transform: translateY(0); }
  }
  @keyframes sv-dip {
    0%   { transform: translateY(0); }
    50%  { transform: translateY(7px); }
    100% { transform: translateY(0); }
  }

  /* ── the bracket that names the pair being weighed ─────────────── */
  .sv-bracket {
    position: absolute;
    left: 0; bottom: -13px;
    width: 128px;              /* pitch + bar width */
    height: 7px;
    opacity: 0;
    transform: translateX(var(--bx, 0px));
    color: var(--clay);
    border-bottom: 1.5px solid currentColor;
    transition:
      transform    380ms var(--ease-in-out),
      opacity      220ms var(--ease-out),
      color        240ms var(--ease-out);
  }
  .sv-bracket::before,
  .sv-bracket::after {
    content: "";
    position: absolute;
    bottom: 0;
    width: 1.5px; height: 7px;
    background: currentColor;
  }
  .sv-bracket::before { left: 0; }
  .sv-bracket::after  { right: 0; }
  .sv-bracket.on { opacity: 1; }
  .sv-bracket.hold { color: var(--olive); }

  /* ── readout ───────────────────────────────────────────────────── */
  .sv-read {
    height: 46px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 14px;
    margin-top: 22px;
  }
  .expr, .verdict {
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 240ms var(--ease-out), transform 240ms var(--ease-out);
  }
  .expr.on, .verdict.on { opacity: 1; transform: translateY(0); }

  .expr {
    font-size: 17px;
    letter-spacing: 0.06em;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
  }
  .expr .op { color: var(--gray-500); padding: 0 4px; }

  .verdict {
    font-size: 10.5px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    padding: 4px 9px;
    border: 1px solid var(--clay);
    border-radius: 999px;
    color: var(--clay);
  }
  .verdict.keep { border-color: var(--olive); color: var(--olive); }
  .verdict.done {
    border-color: var(--olive);
    color: var(--olive);
    font-family: var(--serif);
    text-transform: none;
    letter-spacing: 0.02em;
    font-size: 14px;
    font-style: italic;
    border-color: transparent;
  }

  /* ── loop: entrance and dissolve ───────────────────────────────── */
  .sv-stage.enter .bar {
    opacity: 0;
    transform: translateX(var(--x)) translateY(18px);
    transition-delay: 0ms;
  }
  .sv-stage.run .bar { transition-delay: 0ms; }
  .sv-stage.out .bar {
    opacity: 0;
    transform: translateX(var(--x)) translateY(12px);
  }
  .sv-stage.out .sv-baseline { transform: scaleX(0); transition-duration: 460ms; }

  /* ── reduced motion: hold the settled state, say so, stop ──────── */
  @media (prefers-reduced-motion: reduce) {
    .bar, .body, .num, .sv-bracket, .expr, .verdict,
    .sv-baseline, .sv-head .pass {
      transition-duration: 1ms !important;
      transition-delay: 0ms !important;
      animation: none !important;
    }
    .sv-bracket { opacity: 0 !important; }
  }

  /* ══ KEEP TO HERE ═════════════════════════════════════════════════ */
</style>
</head>
<body>

<div class="card">
  <!-- ══ KEEP: markup ══ -->
  <div class="sortviz">
    <div class="sv-head">
      <span>adjacent comparison &middot; bubble sort</span>
      <span class="pass" id="sv-pass">pass 1</span>
    </div>

    <div class="sv-stage enter" id="sv-stage">
      <div class="sv-bars" id="sv-bars">
        <div class="sv-baseline"></div>
        <div class="sv-bracket" id="sv-bracket"></div>
      </div>
    </div>

    <div class="sv-read">
      <span class="expr" id="sv-expr"></span>
      <span class="verdict" id="sv-verdict"></span>
    </div>
  </div>

  <script>
  /* ══ KEEP: the sequencer ══════════════════════════════════════════
     CSS can express every visual state here, but not the order they
     arrive in — the swap schedule is the algorithm itself. So the
     script does nothing but toggle classes and set two custom
     properties; all motion still lives in the stylesheet. */
  (function () {
    var VALUES = [4, 1, 5, 2, 3];
    var PITCH = 76, UNIT = 38, BASE = 64;

    var stage   = document.getElementById('sv-stage');
    var barsEl  = document.getElementById('sv-bars');
    var bracket = document.getElementById('sv-bracket');
    var exprEl  = document.getElementById('sv-expr');
    var verdEl  = document.getElementById('sv-verdict');
    var passEl  = document.getElementById('sv-pass');

    var bars = VALUES.map(function (v, i) {
      var el = document.createElement('div');
      el.className = 'bar';
      el.style.setProperty('--i', i);
      el.style.setProperty('--x', (i * PITCH) + 'px');
      var hop = document.createElement('div');
      hop.className = 'hop';
      var body = document.createElement('div');
      body.className = 'body';
      body.style.height = (BASE + v * UNIT) + 'px';
      var num = document.createElement('span');
      num.className = 'num';
      num.textContent = v;
      body.appendChild(num); hop.appendChild(body); el.appendChild(hop);
      barsEl.appendChild(el);
      return { el: el, hop: hop, v: v };
    });

    /* Record the run first, then play it back. Bubble sort with the
       early exit, so the loop ends the moment a pass is clean. */
    function schedule() {
      var arr = bars.map(function (b, i) { return { i: i, v: b.v }; });
      var n = arr.length, out = [], pass;
      for (pass = 1; pass < n; pass++) {
        var swapped = false;
        for (var k = 0; k < n - pass; k++) {
          var a = arr[k], b = arr[k + 1], sw = a.v > b.v;
          out.push({ t: 'cmp', pass: pass, k: k, a: a, b: b, swap: sw });
          if (sw) { arr[k] = b; arr[k + 1] = a; swapped = true; }
        }
        out.push({ t: 'settle', at: n - pass - 1, ids: arr.slice(n - pass) });
        if (!swapped) { out.push({ t: 'done', ids: arr.slice() }); break; }
      }
      return { steps: out, order: arr };
    }

    var plan = schedule();

    function fit() {
      var w = stage.clientWidth;
      barsEl.style.setProperty('--fit', Math.min(1, w / 372).toFixed(3));
    }
    fit();
    window.addEventListener('resize', fit);

    var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

    function place(order) {
      order.forEach(function (item, slot) {
        bars[item.i].el.style.setProperty('--x', (slot * PITCH) + 'px');
      });
    }
    function reset() {
      bars.forEach(function (b, i) {
        b.el.className = 'bar';
        b.el.style.setProperty('--x', (i * PITCH) + 'px');
      });
      bracket.className = 'sv-bracket';
      exprEl.className = 'expr'; verdEl.className = 'verdict';
      passEl.textContent = 'pass 1';
    }

    /* Settled state, no motion: what a reduced-motion viewer sees. */
    if (reduced.matches) {
      place(plan.order);
      bars.forEach(function (b) { b.el.classList.add('settled'); });
      stage.classList.remove('enter');
      stage.classList.add('ready');
      exprEl.innerHTML = '1&nbsp;&nbsp;2&nbsp;&nbsp;3&nbsp;&nbsp;4&nbsp;&nbsp;5';
      exprEl.classList.add('on');
      verdEl.textContent = 'sorted';
      verdEl.className = 'verdict done on';
      passEl.textContent = 'settled';
      return;
    }

    var sleep = function (ms) {
      return new Promise(function (r) { setTimeout(r, ms); });
    };

    function cross(a, b) {
      a.el.classList.add('over');
      b.el.classList.add('under');
      /* restart the arc keyframes */
      a.hop.style.animation = 'none'; b.hop.style.animation = 'none';
      void a.hop.offsetWidth;
      a.hop.style.animation = ''; b.hop.style.animation = '';
    }

    async function play() {
      for (;;) {
        reset();
        stage.classList.add('enter');
        stage.classList.remove('run', 'out', 'ready');
        void barsEl.offsetWidth;

        stage.classList.remove('enter');
        stage.classList.add('ready');
        await sleep(900);
        stage.classList.add('run');

        var order = bars.map(function (b, i) { return { i: i, v: b.v }; });

        for (var s = 0; s < plan.steps.length; s++) {
          var st = plan.steps[s];

          if (st.t === 'cmp') {
            passEl.textContent = 'pass ' + st.pass;
            var A = bars[order[st.k].i], B = bars[order[st.k + 1].i];

            /* 1. name the pair */
            barsEl.classList.add('compare');
            A.el.classList.add('active'); B.el.classList.add('active');
            bracket.style.setProperty('--bx', (st.k * PITCH) + 'px');
            bracket.className = 'sv-bracket on' + (st.swap ? '' : ' hold');
            await sleep(340);

            /* 2. write the comparison out */
            exprEl.innerHTML = st.a.v + '<span class="op">' +
              (st.swap ? '&gt;' : '&lt;') + '</span>' + st.b.v;
            exprEl.classList.add('on');
            await sleep(300);

            /* 3. state the consequence */
            verdEl.textContent = st.swap ? 'swap' : 'in order';
            verdEl.className = 'verdict on' + (st.swap ? '' : ' keep');
            await sleep(st.swap ? 200 : 380);

            /* 4. act on it */
            if (st.swap) {
              cross(A, B);
              var tmp = order[st.k];
              order[st.k] = order[st.k + 1];
              order[st.k + 1] = tmp;
              place(order);
              await sleep(620);
              A.el.classList.remove('over');
              B.el.classList.remove('under');
            }

            A.el.classList.remove('active'); B.el.classList.remove('active');
            barsEl.classList.remove('compare');
            exprEl.classList.remove('on'); verdEl.classList.remove('on');
            bracket.classList.remove('on');
            await sleep(200);

          } else if (st.t === 'settle') {
            st.ids.forEach(function (item) {
              bars[item.i].el.classList.add('settled');
            });
            await sleep(280);

          } else if (st.t === 'done') {
            bars.forEach(function (b) { b.el.classList.add('settled'); });
            passEl.textContent = 'no swaps — done';
            exprEl.innerHTML = '1&nbsp;&nbsp;2&nbsp;&nbsp;3&nbsp;&nbsp;4&nbsp;&nbsp;5';
            exprEl.classList.add('on');
            verdEl.textContent = 'sorted';
            verdEl.className = 'verdict done on';
          }
        }

        /* hold the settled state a beat, then dissolve and replay */
        await sleep(1900);
        stage.classList.remove('run');
        stage.classList.add('out');
        exprEl.classList.remove('on'); verdEl.classList.remove('on');
        passEl.style.opacity = '0';
        await sleep(900);
        passEl.style.opacity = '';
        await sleep(200);
      }
    }

    play();
  })();
  </script>
  <!-- ══ KEEP: end ══ -->
</div>

</body>
</html>