Back to the library

Text cycle — typewriter

Variant: Typewriter

A status line that types a word, holds it, deletes it and types the next — and the caret is the reason to copy this build rather than the twenty above it on a search. It is the left border of the paper-coloured cover that does the typing, so it cannot drift out of step with the text it is producing; there is no second timeline to keep aligned. The two halves run at different rates on purpose: 62ms a character going out, 34ms coming back, because typing is a hand and deleting is a held key. And the caret does not blink while either is happening — it blinks only through the 1.2s hold, which is the tell that separates a typewriter from a caret and a text animation that happen to share a box. Every word is exactly ten characters, so one steps(10) serves all three and the slot never resizes underneath them.

textloopstatus

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 — typewriter</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 — 7.2s, three 2.4s slots. Every word is exactly ten characters,
           which is what lets one steps(10) serve all three and keeps the slot
           from resizing underneath them. */
        --cycle: 7.2s;
        --t: calc(var(--cycle) / var(--speed));

        --_read: color-mix(in srgb, var(--slate) 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);
      }

      /* The cover parks a full slot-width to the right of the word once it is
         typed, so the line clips it rather than letting a paper-coloured block
         travel out over the card's border. */
      .line {
        margin-top: clamp(9px, 1.8vw, 14px);
        overflow: hidden;
        white-space: nowrap;
        font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
        font-size: clamp(14px, 4.4vw, 22px);
        line-height: 1.5;
        color: var(--_read);
      }

      /* Exactly ten characters wide, in ch, so a steps(10) translate lands on a
         glyph boundary at every stage size instead of near one. */
      .slot {
        position: relative;
        display: inline-block;
        width: 10ch;
      }

      /* The first word stays in flow and is what gives the slot its height; the
         other two are stacked on it. All three are the same ten characters, so
         which one holds the box open is arbitrary. */
      .word {
        position: absolute;
        inset: 0;
        opacity: 0;
        color: var(--slate);
        animation: slot var(--t) infinite;
        animation-delay: calc(var(--_d) / var(--speed));
      }

      .word:first-child {
        position: relative;
        --_d: 0s;
      }

      .word:nth-child(2) {
        --_d: 2.4s;
      }

      .word:nth-child(3) {
        --_d: 4.8s;
      }

      /* One word owns the slot for exactly a third of the cycle. The handover is
         invisible because the outgoing word has already been deleted and the
         incoming one arrives fully covered — both leave a caret parked at column
         zero, in the same place, so the line never loses it. */
      @keyframes slot {
        0% {
          opacity: 1;
          animation-timing-function: steps(1, jump-end);
        }
        33.333%,
        100% {
          opacity: 0;
        }
      }

      /* The cover is the typing. It sits over the word in paper and retreats
         right one character at a time; its left border is the caret, so the
         caret cannot drift out of step with the text it is producing — there is
         no second timeline to keep aligned. */
      .cover {
        position: absolute;
        inset: 0;
        background: var(--paper);
        border-left: 0.085em solid var(--clay);
        transform: translateX(0);
        animation:
          type var(--t) infinite,
          caret var(--t) infinite;
        animation-delay: calc(var(--_d) / var(--speed));
      }

      /* 62ms a character going out, 34ms coming back. Typing is a hand and
         deleting is a held key, so the two halves have no business sharing a
         rate. jump-start on both, because the first character has to appear on
         the frame the caret starts moving — with the default end variant the
         line sits empty for a full character-time and reads as a dropped frame. */
      @keyframes type {
        0% {
          transform: translateX(0);
          animation-timing-function: steps(10, jump-start);
        }
        8.611% {
          transform: translateX(100%);
        }
        25.278% {
          transform: translateX(100%);
          animation-timing-function: steps(10, jump-start);
        }
        30%,
        100% {
          transform: translateX(0);
        }
      }

      /* The caret does not blink while the hand is moving — it blinks only
         through the 1.2s hold, on a 400ms period. A caret that blinks through
         its own typing is the tell that the two are unrelated animations bolted
         together, and it costs the piece the one thing it is claiming. */
      @keyframes caret {
        0% {
          border-left-color: var(--clay);
          animation-timing-function: steps(1, jump-end);
        }
        11.389% {
          border-left-color: transparent;
          animation-timing-function: steps(1, jump-end);
        }
        14.167% {
          border-left-color: var(--clay);
          animation-timing-function: steps(1, jump-end);
        }
        16.944% {
          border-left-color: transparent;
          animation-timing-function: steps(1, jump-end);
        }
        19.722% {
          border-left-color: var(--clay);
          animation-timing-function: steps(1, jump-end);
        }
        22.5% {
          border-left-color: transparent;
          animation-timing-function: steps(1, jump-end);
        }
        25.278%,
        100% {
          border-left-color: var(--clay);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent. The travel and the blink both go — a caret
           flickering six times a cycle is its own hazard for exactly this
           audience — and the words crossfade in place instead. The cover parks
           fully retreated so the caret still sits after the last character,
           which is the only part of it that was ever load-bearing. */
        .cover {
          animation: none;
          transform: translateX(100%);
          border-left-color: var(--clay);
        }

        .word {
          animation-name: slot-rm;
          animation-duration: calc(var(--t) * 1.7);
        }

        @keyframes slot-rm {
          0% {
            opacity: 0;
            animation-timing-function: ease;
          }
          4%,
          29% {
            opacity: 1;
            animation-timing-function: ease;
          }
          33.333%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Deploy status</p>
      <p class="line" role="img" aria-label="Waiting for migrations, the review, a rollback.">
        waiting for
        <span class="slot"
          ><span class="word">migrations<span class="cover"></span></span
          ><span class="word">the review<span class="cover"></span></span
          ><span class="word">a rollback<span class="cover"></span></span
        ></span>
      </p>
    </div>
  </body>
</html>

The variant family

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