Back to the library

Text edit — backspace and retype

Variant: Backspace and retype

The live edit, and the tail is the piece: everything after the cursor rides the deletion left in 1ch steps and rides the retype back, because the tail's stepped translate shares the exact steps() clocks driving the two words' clips — there is no second timeline to drift against, which is the same reason the typewriter's caret is a border. Deleting runs 41ms a character and typing 90ms, a held key against a hand. The caret blinks only at rest — twice before the edit, once inside the empty quotes — holds solid while it works, and fades out entirely once the edit is done: a finished edit has no further claim on the reader.

textdiffcontrolloop

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 — backspace and retype</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.2s. Backspace runs 18–23.5% at 41ms a character, the empty
           quotes hold for 600ms, and the retype runs 35–40.2% at 90ms a
           character. Deleting is a held key; typing is a hand. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        --_read: color-mix(in srgb, var(--slate) 78%, transparent);
      }

      * {
        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 is load-bearing: every deletion moves the tail exactly 1ch,
         so the tail's stepped translate and the word's stepped clip can share
         one clock and land on the same glyph boundaries at every stage size. */
      .line {
        position: relative;
        margin-top: clamp(10px, 2vw, 15px);
        white-space: pre;
        font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
        font-size: clamp(15px, 4.6vw, 21px);
        line-height: 1.6;
        color: var(--_read);
        animation: seam var(--t) infinite;
      }

      .old,
      .new,
      .tail {
        display: inline-block;
      }

      /* The old word deletes from its right edge, one character a step. The
         clip is the deletion — the element never leaves flow, so nothing else
         in the line has to know the edit is happening. */
      .old {
        animation: backspace var(--t) infinite;
      }

      /* The replacement occupies its own flow slot after the old word, pulled
         back 7ch onto the old word's origin. It stays fully clipped until the
         retype, so the pull-back can never be seen. */
      .new {
        transform: translateX(-7ch);
        animation: retype var(--t) infinite;
      }

      /* The tail is the piece: the characters after an edit ride it. Its
         translate steps on the same steps() clocks as the two clips, which is
         why it can never drift out of sync with the letters — there is no
         second timeline to keep aligned. */
      .tail {
        animation: ride var(--t) infinite;
      }

      .caret {
        position: absolute;
        top: 0.14em;
        left: 0;
        width: max(2px, 0.085em);
        height: 1.3em;
        border-radius: 999px;
        background: var(--clay);
        animation:
          caret-move var(--t) infinite,
          caret-blink var(--t) infinite;
      }

      @keyframes backspace {
        0%,
        18% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: steps(7, jump-start);
        }
        23.5%,
        94% {
          clip-path: inset(0 100% 0 0);
        }
        95%,
        100% {
          clip-path: inset(0 0 0 0);
        }
      }

      @keyframes retype {
        0%,
        35% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: steps(3, jump-start);
        }
        40.2%,
        94% {
          clip-path: inset(0 0 0 0);
        }
        95%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      /* Seven 1ch steps left with the deletions, three 1ch steps right with
         the retype. jump-start throughout, matching the clips: the first
         character moves on the frame the edit starts, not a character-time
         after it. */
      @keyframes ride {
        0%,
        18% {
          transform: translateX(-3ch);
          animation-timing-function: steps(7, jump-start);
        }
        23.5%,
        35% {
          transform: translateX(-10ch);
          animation-timing-function: steps(3, jump-start);
        }
        40.2%,
        94% {
          transform: translateX(-7ch);
        }
        95%,
        100% {
          transform: translateX(-3ch);
        }
      }

      @keyframes caret-move {
        0%,
        18% {
          transform: translateX(13ch);
          animation-timing-function: steps(7, jump-start);
        }
        23.5%,
        35% {
          transform: translateX(6ch);
          animation-timing-function: steps(3, jump-start);
        }
        40.2%,
        94% {
          transform: translateX(9ch);
        }
        95%,
        100% {
          transform: translateX(13ch);
        }
      }

      /* The caret blinks only at rest — twice before the edit, once inside the
         empty quotes — and holds solid while it works: a caret that blinks
         through its own typing is two unrelated animations bolted together.
         Once the edit is done it fades out entirely; a finished edit has no
         further claim on the reader. */
      @keyframes caret-blink {
        0%,
        3.84% {
          opacity: 1;
        }
        3.85%,
        7.69% {
          opacity: 0;
        }
        7.7%,
        11.54% {
          opacity: 1;
        }
        11.55%,
        15.3% {
          opacity: 0;
        }
        15.4%,
        26% {
          opacity: 1;
        }
        26.1%,
        29.8% {
          opacity: 0;
        }
        29.85%,
        46% {
          opacity: 1;
          animation-timing-function: ease;
        }
        50%,
        94% {
          opacity: 0;
        }
        95%,
        100% {
          opacity: 1;
        }
      }

      /* The rewind hides in a dark window at the seam. */
      @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 stepped travel is the thing being removed: the two words
           crossfade in place on a slower clock, the tail snaps between its
           two rests while both words are faded, and the caret — whose entire
           argument was travel — goes with it. */
        .line,
        .old,
        .new,
        .tail {
          animation-duration: calc(var(--t) * 1.7);
        }

        .caret {
          animation: none;
          opacity: 0;
        }

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

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

        .tail {
          animation-name: ride-rm;
        }

        @keyframes backspace-rm {
          0%,
          25% {
            opacity: 1;
            animation-timing-function: ease;
          }
          32%,
          94% {
            opacity: 0;
          }
          95%,
          100% {
            opacity: 1;
          }
        }

        @keyframes retype-rm {
          0%,
          33% {
            opacity: 0;
            animation-timing-function: ease;
          }
          40%,
          94% {
            opacity: 1;
          }
          95%,
          100% {
            opacity: 0;
          }
        }

        @keyframes ride-rm {
          0%,
          32.4% {
            transform: translateX(-3ch);
          }
          32.5%,
          94% {
            transform: translateX(-7ch);
          }
          95%,
          100% {
            transform: translateX(-3ch);
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">release.ts</p>
      <p
        class="line"
        role="img"
        aria-label="The line ship(&quot;someday&quot;); has someday backspaced out and now typed in its place."
      ><span class="head">ship("</span><span class="old">someday</span><span class="new">now</span><span class="tail">");</span><span class="caret"></span></p>
    </div>
  </body>
</html>