Back to the library

Text cycle — scramble to resolve

Variant: Scramble to resolve

A value arriving from somewhere else, settling one column at a time from the left. Two layers share a single clip edge driven by one steps(12) — the resolved string clipped from the right, the churning one clipped as its exact complement — which is why a character can never be drawn twice or dropped on the handover frame. The churn runs on its own 0.72s clock, deliberately not a divisor of the main one, so the noise never lands on the same glyph each time the front passes a column. Colour does the state: unresolved columns sit in clay because they are still in flight, and each one goes slate as the edge crosses it, so the boundary is legible without a marker drawn on it. Reach for it on an id, a hash or a token that was computed rather than typed — the churn is the claim that work happened, and the resolve tells the eye where to start reading.

textloopdatastatus

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 cycle — scramble to resolve</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. The resolve itself is 1.1s of it: twelve characters at
           92ms, which is slow enough that the eye can watch the front advance
           and fast enough that nobody is waiting on a value they can already
           half read. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        /* The churn runs on its own clock and is deliberately not a divisor of
           the main one — a scramble that lands on the same glyph every time the
           front passes a column stops looking like noise. */
        --_churn: 0.72s;
        --_read: color-mix(in srgb, var(--slate) 55%, transparent);
        --_pending: color-mix(in srgb, var(--clay) 62%, 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);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .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: var(--_read);
      }

      /* Twelve characters wide in ch, so the clip edge that both layers share
         lands on a glyph boundary rather than through one. A proportional face
         would put the boundary inside a letter at almost every step and the
         resolve would read as a wipe. */
      .value {
        position: relative;
        display: inline-block;
        margin-top: clamp(8px, 1.6vw, 13px);
        width: 12ch;
        font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
        font-size: clamp(17px, 5.4vw, 28px);
        line-height: 1.35;
        letter-spacing: 0;
        animation: reset var(--t) infinite;
      }

      .real {
        position: relative;
        display: block;
        white-space: pre;
        color: var(--slate);
        clip-path: inset(0 100% 0 0);
        animation: resolve var(--t) infinite;
      }

      .noise {
        position: absolute;
        inset: 0;
        overflow: hidden;
        color: var(--_pending);
        clip-path: inset(0 0 0 0);
        animation: dissolve var(--t) infinite;
      }

      /* Twenty glyphs behind a twelve-glyph window, stepped a column at a time.
         The eight offsets are all the variety a churn needs: nobody is tracking
         which character came back, only that the column has not settled. */
      .churn {
        display: block;
        white-space: pre;
        animation: churn calc(var(--_churn) / var(--speed)) steps(8) infinite;
      }

      @keyframes churn {
        from {
          transform: translateX(0);
        }
        to {
          transform: translateX(-8ch);
        }
      }

      /* The two clips are exact complements driven by the same steps(12), which
         is the entire reason a character can never be drawn twice or dropped on
         the handover frame — there is one edge in this file, and both layers are
         reading it. jump-start so the first column settles on the frame the
         resolve begins rather than a character-time later. */
      @keyframes resolve {
        0% {
          clip-path: inset(0 100% 0 0);
        }
        10% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: steps(12, jump-start);
        }
        31.15%,
        94% {
          clip-path: inset(0 0 0 0);
        }
        94.1%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      @keyframes dissolve {
        0% {
          clip-path: inset(0 0 0 0);
        }
        10% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: steps(12, jump-start);
        }
        31.15%,
        94% {
          clip-path: inset(0 0 0 100%);
        }
        94.1%,
        100% {
          clip-path: inset(0 0 0 0);
        }
      }

      /* The value goes to zero between 91% and 96.5% and both clips snap back
         inside that window, so the resolve never plays in reverse. The return
         is a fade rather than a cut because a value re-entering unresolved is
         the honest opening frame — it has not arrived yet. */
      @keyframes reset {
        0% {
          opacity: 1;
          animation-timing-function: ease;
        }
        88% {
          opacity: 1;
          animation-timing-function: ease;
        }
        91%,
        96.5% {
          opacity: 0;
          animation-timing-function: ease;
        }
        100% {
          opacity: 1;
        }
      }

      .meta {
        margin-top: clamp(6px, 1.2vw, 10px);
        font-size: clamp(11.5px, 2.6vw, 13.5px);
        color: var(--_read);
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent. The resolve stays — it is the meaning — but the
           churn drops to a third of its rate, because a glyph changing every
           90ms is a flicker before it is an animation and this is the audience
           that setting exists for. */
        .value {
          animation-duration: calc(var(--t) * 1.7);
        }

        .real,
        .noise {
          animation-duration: calc(var(--t) * 1.7);
        }

        .churn {
          animation-duration: calc(var(--_churn) * 3 / var(--speed));
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Commit</p>
      <div class="value" role="img" aria-label="Commit 8f2c41ae9d3b resolving.">
        <span class="real" aria-hidden="true">8f2c41ae9d3b</span>
        <span class="noise" aria-hidden="true">
          <span class="churn">3b7f0a9e4c1d6528af07</span>
        </span>
      </div>
      <p class="meta">main · signed · 2 minutes ago</p>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.