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);

    /* one frame gutter, continuous with the frame height */
    --pad: clamp(8px, 3.5vh, 24px);
  }

  * { 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: var(--pad);
    -webkit-font-smoothing: antialiased;
  }

  /* ── page chrome (not part of the piece) ───────────────────────── */
  .card {
    width: 100%;
    max-width: 660px;
    aspect-ratio: 4 / 3;
    /* the ratio is a wish, not a promise: on any frame shorter than
       4:3 the card gives up height rather than pushing the readout
       out of the bottom. Applies at every width, not below a
       breakpoint. */
    max-height: calc(100vh - 2 * var(--pad));
    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: 11px;
    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;
  }

  /* Centred by hand rather than by the stage's place-items: a grid item wider
     than its area gets clamped to the start edge (so nothing is lost off the
     left), which on a narrow stage threw the whole row right and pushed the
     last bar out of the card. left:50% + half-width back is the same position
     when it fits and the correct one when it does not. */
  .sv-bars {
    position: relative;
    justify-self: start;
    left: 50%;
    margin-left: -178px;   /* half of the width below */
    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;
    /* .num rides inside .sv-bars, which is scaled by --fit, so a
       declared size renders at size * fit. Dividing by --fit holds the
       rendered size at a constant 12.5px on every frame; the min()
       stops it running away if --fit ever gets very small. */
    font-size: min(26px, calc(12.5px / var(--fit, 1)));
    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: 11px;
    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; }

  /* ── small stages ──────────────────────────────────────────────
     The 4:3 card plus 24px of body padding is taller than a phone
     frame, so the card fills the frame instead of holding a ratio,
     and the chrome around the stage gives up its slack: tighter
     padding, the head stacked so its two captions never wrap into
     each other, a shorter readout. The bars themselves are scaled
     by --fit (see the sequencer), which now measures height as well
     as width; .num compensates for that scale on its own, at every
     width, so nothing here has to. ──────────────────────────── */
  @media (max-width: 420px) {
    .card {
      aspect-ratio: auto;
      height: 100%;
      max-width: none;
      border-radius: 10px;
    }

    .sortviz { padding: 12px 10px 10px; }

    .sv-head {
      flex-direction: column;
      align-items: flex-start;
      gap: 2px;
    }

    .sv-read { height: 30px; margin-top: 8px; gap: 10px; }
    .expr    { font-size: 15px; }
  }

  /* ── reduced motion: the sorted row, re-verified forever ───────────
     The settled arrangement is still the claim — nothing moves slot to
     slot, nothing hops, nothing rewinds. What replaces the freeze is
     the *clean final pass*: a slow left-to-right scan that lifts each
     settled bar's opacity and colour in turn and lets it sink back, the
     same adjacent sweep the sorter makes when it finds nothing left to
     swap. Two bars are lit at any instant, so the wave never stalls,
     and the "sorted" verdict breathes alongside it. Opacity and colour
     only; no travel, no jitter, 6s a lap. */
  @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;
    }
    .body, .num, .hop, .sv-bracket, .expr,
    .sv-baseline, .sv-head .pass {
      animation: none !important;
    }
    .sv-bracket { opacity: 0 !important; }

    /* the scan. Delays are keyed to where each bar ends up once sorted
       (DOM order is the unsorted order 4,1,5,2,3; .sv-bars' first two
       children are the baseline and the bracket), so the wave reads
       left to right across the finished row. Negative delays start it
       mid-lap, so an arriving visitor never waits for the first pass. */
    .sv-stage .bar {
      animation: sv-verify-reduced 6s ease-in-out infinite;
    }
    .sv-bars .bar:nth-child(4) { animation-delay: -6.0s; }  /* slot 0 — value 1 */
    .sv-bars .bar:nth-child(6) { animation-delay: -4.8s; }  /* slot 1 — value 2 */
    .sv-bars .bar:nth-child(7) { animation-delay: -3.6s; }  /* slot 2 — value 3 */
    .sv-bars .bar:nth-child(3) { animation-delay: -2.4s; }  /* slot 3 — value 4 */
    .sv-bars .bar:nth-child(5) { animation-delay: -1.2s; }  /* slot 4 — value 5 */

    .sv-read .verdict.on {
      animation: sv-affirm-reduced 4.8s ease-in-out infinite;
    }

    @keyframes sv-verify-reduced {
      0%        { opacity: 0.55; filter: saturate(0.55); }
      20%       { opacity: 1;    filter: saturate(1.2); }
      40%, 100% { opacity: 0.55; filter: saturate(0.55); }
    }
    @keyframes sv-affirm-reduced {
      0%, 100% { opacity: 0.55; }
      50%      { opacity: 1; }
    }
  }

  /* ══ 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();

    /* 392 = the bars (356) plus the baseline's 18px overhang each side, so
       the baseline is what the fit is measured against — it is the widest
       thing in the stage. 280 = the tallest bar (64 + 5*38) plus the 26px
       arc a swapping bar rises through. Height matters as much as width on
       a short frame. */
    function fit() {
      var w = stage.clientWidth, h = stage.clientHeight;
      var f = Math.min(1, w / 392, h > 0 ? h / 280 : 1);
      barsEl.style.setProperty('--fit', f.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: what a reduced-motion viewer sees. The stylesheet
       then runs its slow verification sweep over this arrangement. */
    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>