Back to the library

Text edit — redline resolved

Variant: Redline resolved

The tracked-changes beat: a sentence carrying both its readings — the old word struck in rust, the replacement underlined in olive — resolves into clean copy. Order does the arguing. The strike draws linear, a pen crossing the word at hand speed, before the underline endorses the replacement; the struck word finishes fading before the gap closes, because a document closes the space of a word that is gone, never one still being read; and the olive ink takes 560ms to settle into the body’s own ink only after it has landed, since becoming ordinary is what acceptance means. The tail travels exactly 7ch — the piece is set in monospace because proportional type cannot promise where a tail lands. Reach for it where a review flow shows a proposal and its resolution on one surface.

textdiffclip-pathloop

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 — redline resolved</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 — 5.6s: the proposal is readable by 16%, the edit executes
           between 40% and 64%, and the seam hides in a dark window at
           93.5–96.5%. */
        --cycle: 5.6s;
        --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) 62%, transparent);
        --_struck: color-mix(in srgb, var(--rust) 58%, transparent);
        --_olive-ink: color-mix(in oklab, var(--olive) 72%, var(--slate));
      }

      * {
        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(15px, 3.6vw, 24px) 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);
      }

      /* Monospace on purpose, as in the splice piece: the resolution slides
         the tail left by exactly the struck word plus its space — 7ch — and
         only a mono face can promise that 7ch is where the tail lands. */
      .line {
        margin-top: clamp(10px, 2vw, 15px);
        white-space: pre;
        font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
        font-size: clamp(14px, 4vw, 18px);
        line-height: 1.6;
        color: var(--_read);
        animation: seam var(--t) infinite;
      }

      .old,
      .new,
      .shift {
        position: relative;
        display: inline-block;
      }

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

      /* The strike sits at x-height middle and draws left to right, linear —
         it is a pen crossing the word at the hand's speed, and easing it puts a
         deceleration in the ink no hand put there. */
      .old::after {
        content: '';
        position: absolute;
        left: -0.08em;
        right: -0.08em;
        top: 50%;
        height: max(2px, 0.085em);
        transform: translateY(-50%);
        border-radius: 999px;
        background: var(--rust);
        clip-path: inset(0 100% 0 0);
        animation: strike var(--t) infinite;
      }

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

      /* The proposal's underline is olive and arrives on the underline family
         curve — ease-out, because a rule under a word arrives at the end of
         that word rather than passing through it. */
      .new::after {
        content: '';
        position: absolute;
        left: -0.06em;
        right: -0.06em;
        bottom: -0.14em;
        height: max(2px, 0.08em);
        border-radius: 999px;
        background: var(--olive);
        clip-path: inset(0 100% 0 0);
        animation: rule var(--t) infinite;
      }

      .shift {
        animation: close-gap var(--t) infinite;
      }

      /* 0–16%: the proposal marks itself. The strike takes 336ms and the
         underline follows — deletion is claimed before the replacement is
         endorsed, which is the reading order of every redline. */
      @keyframes strike {
        0%,
        4% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: linear;
        }
        10%,
        94% {
          clip-path: inset(0 0 0 0);
        }
        95%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      @keyframes old-ink {
        0%,
        4% {
          color: var(--_read);
          opacity: 1;
          animation-timing-function: ease;
        }
        10%,
        40% {
          color: var(--_struck);
          opacity: 1;
          animation-timing-function: ease;
        }
        43%,
        94% {
          color: var(--_struck);
          opacity: 0;
        }
        95%,
        100% {
          color: var(--_read);
          opacity: 1;
        }
      }

      @keyframes rule {
        0%,
        10% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: var(--ease-out);
        }
        15.5%,
        54% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: var(--ease-out);
        }
        58.5%,
        94% {
          /* It leaves by its trailing edge travelling right — wiped off
             toward the word's end, not un-drawn: withdrawing the mark must
             not read as withdrawing the endorsement. */
          clip-path: inset(0 0 0 100%);
        }
        95%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      /* The replacement opens at 55% opacity so the line never reads as a
         duplicated-word typo, then takes full olive ink as its underline
         endorses it. It settles to the body's own ink over 560ms only after
         it has landed in place — becoming ordinary is what acceptance means,
         and it is the slowest beat in the file on purpose. */
      @keyframes new-ink {
        0%,
        10% {
          color: var(--_olive-ink);
          opacity: 0.55;
          animation-timing-function: ease;
        }
        15.5%,
        54% {
          color: var(--_olive-ink);
          opacity: 1;
          animation-timing-function: ease;
        }
        64%,
        94% {
          color: var(--_read);
          opacity: 1;
        }
        95%,
        100% {
          color: var(--_olive-ink);
          opacity: 0.55;
        }
      }

      /* The gap closes only after the struck word has finished fading — a
         document closes the space of a word that is gone, never one still
         being read. 448ms on the in-out curve: the tail is already on screen
         and merely moving. */
      @keyframes close-gap {
        0%,
        44% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        52%,
        94% {
          transform: translateX(-7ch);
        }
        95%,
        100% {
          transform: translateX(0);
        }
      }

      /* The loop's rewind happens inside a dark window: the line dims out at
         90%, every element snaps home at 95%, and the proposal is already
         standing when the light comes back. */
      @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) {
        /* Gentler, not absent: the travel goes — the tail snaps home inside a
           single frame instead of sliding — and the marks fade in place rather
           than drawing. The colour story, which carries the meaning, stays. */
        .line,
        .old,
        .new,
        .old::after,
        .new::after,
        .shift {
          animation-duration: calc(var(--t) * 1.7);
        }

        .old::after {
          clip-path: none;
          animation-name: strike-rm;
        }

        .new::after {
          clip-path: none;
          animation-name: rule-rm;
        }

        .shift {
          animation-name: close-gap-rm;
        }

        @keyframes strike-rm {
          0%,
          4% {
            opacity: 0;
            animation-timing-function: ease;
          }
          10%,
          94% {
            opacity: 1;
          }
          95%,
          100% {
            opacity: 0;
          }
        }

        @keyframes rule-rm {
          0%,
          10% {
            opacity: 0;
            animation-timing-function: ease;
          }
          15.5%,
          54% {
            opacity: 1;
            animation-timing-function: ease;
          }
          58.5%,
          94% {
            opacity: 0;
          }
          95%,
          100% {
            opacity: 0;
          }
        }

        @keyframes close-gap-rm {
          0%,
          51.9% {
            transform: translateX(0);
          }
          52%,
          94% {
            transform: translateX(-7ch);
          }
          95%,
          100% {
            transform: translateX(0);
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Tracked changes</p>
      <p
        class="line"
        role="img"
        aria-label="The sentence 'Ship it on Friday.' with Friday struck through and replaced by Monday."
      ><span class="head">Ship it on </span><span class="old">Friday</span> <span class="shift"><span class="new">Monday</span><span class="tail">.</span></span></p>
    </div>
  </body>
</html>