Back to the library

Text edit — the diff reflows

Variant: Diff reflow

A hunk landing in a code file: one line out in rust, one in with olive, and the document reflowing around both. The DOM holds the final state and transforms produce every other one, so height never animates anywhere in the file. The lines below a removal travel together, one block one row up — they are one document, and staggering them would read as the file coming apart — while the insertion gets the reorder rule: the room opens 32ms before the new line starts arriving. The slowest beat is the olive wash taking 1.15s to decay into plain paper, which is the diff becoming the document; the deleted line leaves 12px to the left, off the page rather than off the screen. Reach for it when a patch landing should read as an edit to a document rather than a swap of screenshots.

textdifflisttransitionloop

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>Text edit — the diff reflows</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — 6.4s: removal lands by 34%, insertion by 50%, and the olive
           wash has fully become the document by 70%. */
        --cycle: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

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

        --_read: color-mix(in srgb, var(--slate) 74%, transparent);
        --_struck: color-mix(in srgb, var(--slate) 42%, transparent);
        --_cut-wash: color-mix(in srgb, var(--rust) 12%, transparent);
        --_add-wash: color-mix(in srgb, var(--olive) 14%, transparent);
        --_row: 2em; /* every reflow travels whole rows of exactly this height */
      }

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

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: clamp(12px, 3vw, 28px);
        background: var(--ivory);
        color: var(--slate);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
      }

      .card {
        width: min(360px, 92vw);
        padding: clamp(14px, 3.4vw, 22px) clamp(16px, 3.8vw, 26px);
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
      }

      .eyebrow {
        font-size: clamp(11px, 2.4vw, 12.5px);
        font-weight: 600;
        letter-spacing: 0.09em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--slate) 55%, transparent);
      }

      /* The DOM holds the document's final state — both the removed and the
         added line are in flow — and transforms alone produce the before, the
         between, and the after. Height never animates anywhere in the file. */
      .code {
        margin-top: clamp(9px, 1.8vw, 13px);
        /* The DOM holds six rows but the choreography only ever shows five —
           the bottom flow row is permanently spare, so give it back to the
           card. Nothing ever translates below its flow slot, so nothing can
           be clipped by this. */
        margin-bottom: calc(-1 * var(--_row));
        padding-left: 2ch;
        font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
        font-size: clamp(11.5px, 3.2vw, 13.5px);
        color: var(--_read);
        animation: seam var(--t) infinite;
      }

      .ln {
        position: relative;
        height: var(--_row);
        line-height: var(--_row);
        white-space: pre;
        border-radius: calc(4px * var(--round));
      }

      .ln::before {
        position: absolute;
        left: -1.7ch;
        opacity: 0;
        font-weight: 600;
      }

      .cut {
        animation: cut var(--t) infinite;
      }

      .cut::before {
        content: '-';
        color: var(--rust);
        animation: sign-cut var(--t) infinite;
      }

      .up {
        animation: up var(--t) infinite;
      }

      .below {
        animation: below var(--t) infinite;
      }

      .add {
        animation: add var(--t) infinite;
      }

      .add::before {
        content: '+';
        color: var(--olive);
        animation: sign-add var(--t) infinite;
      }

      /* The removed line is marked before it is touched — 160ms of rust wash,
         so the reader knows which line is about to go — then leaves 12px to
         the left: deleted lines leave the document, not the screen, and a long
         exit would claim a journey the edit does not have. */
      @keyframes cut {
        0%,
        18% {
          background: transparent;
          color: var(--_read);
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: ease;
        }
        20.5%,
        22% {
          background: var(--_cut-wash);
          color: var(--_struck);
          transform: translateX(0);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        26%,
        94% {
          background: var(--_cut-wash);
          color: var(--_struck);
          transform: translateX(-12px);
          opacity: 0;
        }
        95%,
        100% {
          background: transparent;
          color: var(--_read);
          transform: translateX(0);
          opacity: 1;
        }
      }

      @keyframes sign-cut {
        0%,
        18% {
          opacity: 0;
          animation-timing-function: ease;
        }
        20.5%,
        94% {
          opacity: 1;
        }
        95%,
        100% {
          opacity: 0;
        }
      }

      /* Everything below a removal travels together, as one block, one row up —
         these lines are one document, and staggering them would read as the
         file coming apart. 384ms on the in-out curve: already on screen,
         merely moving. */
      @keyframes up {
        0%,
        28% {
          transform: translateY(0);
          animation-timing-function: var(--ease-in-out);
        }
        34%,
        94% {
          transform: translateY(calc(-1 * var(--_row)));
        }
        95%,
        100% {
          transform: translateY(0);
        }
      }

      /* The two bottom lines carry the whole choreography: pre-lifted one row
         to hide the addition's slot, up a second row when the removal's gap
         closes, then back down one to make room — and the room opens 32ms
         before the new line starts arriving, because the document has to look
         like it already knew something was coming. */
      @keyframes below {
        0%,
        28% {
          transform: translateY(calc(-1 * var(--_row)));
          animation-timing-function: var(--ease-in-out);
        }
        34%,
        40% {
          transform: translateY(calc(-2 * var(--_row)));
          animation-timing-function: var(--ease-in-out);
        }
        44.5%,
        94% {
          transform: translateY(calc(-1 * var(--_row)));
        }
        95%,
        100% {
          transform: translateY(calc(-1 * var(--_row)));
        }
      }

      /* The added line arrives into space that already exists, from the same
         12px the removal left toward. Its olive wash then takes 1.15s to decay
         — the slowest beat in the file, because a diff becoming the document
         is a state change nobody is waiting on. */
      @keyframes add {
        0%,
        45% {
          opacity: 0;
          transform: translate(-12px, calc(-1 * var(--_row)));
          background: var(--_add-wash);
          animation-timing-function: var(--ease-out);
        }
        50%,
        52% {
          opacity: 1;
          transform: translate(0, calc(-1 * var(--_row)));
          background: var(--_add-wash);
          animation-timing-function: ease;
        }
        70%,
        94% {
          opacity: 1;
          transform: translate(0, calc(-1 * var(--_row)));
          background: transparent;
        }
        95%,
        100% {
          opacity: 0;
          transform: translate(-12px, calc(-1 * var(--_row)));
          background: var(--_add-wash);
        }
      }

      @keyframes sign-add {
        0%,
        45% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        50%,
        60% {
          opacity: 1;
          animation-timing-function: ease;
        }
        70%,
        94% {
          opacity: 0;
        }
        95%,
        100% {
          opacity: 0;
        }
      }

      /* The rewind hides in a dark window: dim by 93.5%, snap home at 95%,
         lights back up on the before-state. */
      @keyframes seam {
        0%,
        90% {
          opacity: 1;
          animation-timing-function: ease;
        }
        93.5%,
        96.5% {
          opacity: 0;
          animation-timing-function: ease;
        }
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The reflow travel goes — rows snap between their slots inside a
           frame — and the sideways slides go with it. The washes and fades,
           which say what was removed and what arrived, keep running on a
           slower clock. */
        .code,
        .ln,
        .ln::before {
          animation-duration: calc(var(--t) * 1.7);
        }

        .cut {
          animation-name: cut-rm;
        }

        .up {
          animation-name: up-rm;
        }

        .below {
          animation-name: below-rm;
        }

        .add {
          animation-name: add-rm;
        }

        @keyframes cut-rm {
          0%,
          18% {
            background: transparent;
            color: var(--_read);
            opacity: 1;
            animation-timing-function: ease;
          }
          20.5%,
          22% {
            background: var(--_cut-wash);
            color: var(--_struck);
            opacity: 1;
            animation-timing-function: ease;
          }
          26%,
          94% {
            background: var(--_cut-wash);
            color: var(--_struck);
            opacity: 0;
          }
          95%,
          100% {
            background: transparent;
            color: var(--_read);
            opacity: 1;
          }
        }

        @keyframes up-rm {
          0%,
          33.9% {
            transform: translateY(0);
          }
          34%,
          94% {
            transform: translateY(calc(-1 * var(--_row)));
          }
          95%,
          100% {
            transform: translateY(0);
          }
        }

        @keyframes below-rm {
          0%,
          33.9% {
            transform: translateY(calc(-1 * var(--_row)));
          }
          34%,
          44.4% {
            transform: translateY(calc(-2 * var(--_row)));
          }
          44.5%,
          100% {
            transform: translateY(calc(-1 * var(--_row)));
          }
        }

        @keyframes add-rm {
          0%,
          45% {
            opacity: 0;
            transform: translateY(calc(-1 * var(--_row)));
            background: var(--_add-wash);
            animation-timing-function: ease;
          }
          50%,
          52% {
            opacity: 1;
            transform: translateY(calc(-1 * var(--_row)));
            background: var(--_add-wash);
            animation-timing-function: ease;
          }
          70%,
          94% {
            opacity: 1;
            transform: translateY(calc(-1 * var(--_row)));
            background: transparent;
          }
          95%,
          100% {
            opacity: 0;
            transform: translateY(calc(-1 * var(--_row)));
            background: var(--_add-wash);
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Reviewing retry.ts</p>
      <div
        class="code"
        role="img"
        aria-label="A diff on a retry function: the fixed sleep(500) line is removed and sleep(500 * 2 ** n) is added after the dequeue line."
      >
        <div class="ln">function retry(n) {</div>
        <div class="ln cut">  sleep(500);</div>
        <div class="ln up">  const job = queue[n];</div>
        <div class="ln add">  sleep(500 * 2 ** n);</div>
        <div class="ln below">  return send(job);</div>
        <div class="ln below">}</div>
      </div>
    </div>
  </body>
</html>