Back to the library

Text cycle — glitch

Variant: Glitch

The loudest thing in the library and the one with the narrowest brief: 464ms of disturbance inside a 5.2s loop, a 9% duty cycle, on a label whose entire message is that a connection is intermittent. Three decisions keep it from being decoration. The bursts are two different lengths and the gaps between them are 35% and 62% of the loop, because interference that arrives on the beat stops being interference and becomes a component. Every frame is a steps(1) hold, so nothing is ever caught mid-travel — a glitch that interpolates is a wobble, and the read comes from each frame being a discrete wrong state. And the two ghosts cut at four frames against five, on separate phases, so they come apart rather than reading as one displaced copy with a colour fringe. The word underneath never moves more than a fiftieth of an em: the ghosts are colour and can be as loud as they like, but the base is the thing being read. Never put it on body copy, and never on anything seen more than a few times a session.

texterrorstatusloop

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 — glitch</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 carrying 464ms of disturbance, a 9% duty cycle. The two
           bursts are 290ms and 174ms and the gaps between them are 35% and 62%
           of the loop, because interference that arrives on the beat stops being
           interference and becomes a component. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

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

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

      .glitch {
        position: relative;
        display: inline-block;
        margin-top: clamp(8px, 1.6vw, 13px);
        font-size: clamp(18px, 5.4vw, 30px);
        font-weight: 700;
        letter-spacing: 0.02em;
        line-height: 1.2;
        white-space: nowrap;
        animation: jolt var(--t) infinite;
      }

      /* Both ghosts are the same string as the word beneath them, taken from one
         attribute — there is no second copy in the markup to fall out of sync
         with the first. Multiply is what stops them reading as three words
         stacked: where a ghost lands on the base it darkens it, and only where
         it has been displaced does it show as its own colour. */
      .glitch::before,
      .glitch::after {
        content: attr(data-text);
        position: absolute;
        inset: 0;
        opacity: 0;
        mix-blend-mode: multiply;
        pointer-events: none;
      }

      .glitch::before {
        color: var(--clay);
        animation: ghost-a var(--t) infinite;
      }

      .glitch::after {
        color: var(--olive);
        animation: ghost-b var(--t) infinite;
      }

      /* Every stop holds until the next one — steps(1, jump-end) throughout, so
         nothing in a burst is ever caught mid-travel. A glitch that interpolates
         is a wobble; the whole read comes from each frame being a discrete
         wrong state and the next one being a different wrong state. */
      @keyframes ghost-a {
        0% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
          animation-timing-function: steps(1, jump-end);
        }
        26% {
          opacity: 1;
          transform: translate(-0.17em, -0.03em);
          clip-path: inset(6% 0 63% 0);
          animation-timing-function: steps(1, jump-end);
        }
        27.115% {
          transform: translate(0.15em, 0.02em);
          clip-path: inset(46% 0 24% 0);
          animation-timing-function: steps(1, jump-end);
        }
        28.23% {
          transform: translate(-0.23em, 0);
          clip-path: inset(21% 0 51% 0);
          animation-timing-function: steps(1, jump-end);
        }
        29.346% {
          transform: translate(0.11em, -0.035em);
          clip-path: inset(68% 0 6% 0);
          animation-timing-function: steps(1, jump-end);
        }
        30.46% {
          transform: translate(-0.08em, 0.015em);
          clip-path: inset(33% 0 42% 0);
          animation-timing-function: steps(1, jump-end);
        }
        31.577% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
          animation-timing-function: steps(1, jump-end);
        }
        61% {
          opacity: 1;
          transform: translate(0.18em, 0.03em);
          clip-path: inset(14% 0 55% 0);
          animation-timing-function: steps(1, jump-end);
        }
        62.115% {
          transform: translate(-0.13em, -0.02em);
          clip-path: inset(52% 0 20% 0);
          animation-timing-function: steps(1, jump-end);
        }
        63.23% {
          transform: translate(0.09em, 0);
          clip-path: inset(28% 0 46% 0);
          animation-timing-function: steps(1, jump-end);
        }
        64.346%,
        100% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
        }
      }

      /* The second ghost runs four frames against the first's five, on its own
         phase. Two layers cutting on the same frame read as one displaced copy
         with a colour fringe; two layers cutting at rates that do not divide
         into each other read as a signal coming apart. */
      @keyframes ghost-b {
        0% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
          animation-timing-function: steps(1, jump-end);
        }
        26.5% {
          opacity: 1;
          transform: translate(0.21em, 0.035em);
          clip-path: inset(30% 0 38% 0);
          animation-timing-function: steps(1, jump-end);
        }
        27.9% {
          transform: translate(-0.14em, -0.025em);
          clip-path: inset(62% 0 12% 0);
          animation-timing-function: steps(1, jump-end);
        }
        29.3% {
          transform: translate(0.1em, 0.015em);
          clip-path: inset(10% 0 66% 0);
          animation-timing-function: steps(1, jump-end);
        }
        30.7% {
          transform: translate(-0.19em, 0);
          clip-path: inset(48% 0 30% 0);
          animation-timing-function: steps(1, jump-end);
        }
        31.577% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
          animation-timing-function: steps(1, jump-end);
        }
        61.4% {
          opacity: 1;
          transform: translate(-0.16em, -0.025em);
          clip-path: inset(38% 0 34% 0);
          animation-timing-function: steps(1, jump-end);
        }
        62.9% {
          transform: translate(0.12em, 0.02em);
          clip-path: inset(8% 0 68% 0);
          animation-timing-function: steps(1, jump-end);
        }
        64.346%,
        100% {
          opacity: 0;
          transform: translate(0, 0);
          clip-path: inset(0 0 100% 0);
        }
      }

      /* The word itself moves for two frames of the first burst and one of the
         second, and never more than a twentieth of an em. The ghosts can be as
         loud as they like because they are colour; the base is the thing being
         read, and a word that leaves its position is a word nobody can read. */
      @keyframes jolt {
        0% {
          transform: translateX(0);
          animation-timing-function: steps(1, jump-end);
        }
        28.23% {
          transform: translateX(0.05em);
          animation-timing-function: steps(1, jump-end);
        }
        29.346% {
          transform: translateX(-0.035em);
          animation-timing-function: steps(1, jump-end);
        }
        30.46% {
          transform: translateX(0);
          animation-timing-function: steps(1, jump-end);
        }
        62.115% {
          transform: translateX(0.03em);
          animation-timing-function: steps(1, jump-end);
        }
        63.23%,
        100% {
          transform: translateX(0);
        }
      }

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

      @media (prefers-reduced-motion: reduce) {
        /* This is the one piece in the library where reduced motion has to take
           the whole idea away: stepped displacement at 58ms a frame is the exact
           shape of animation that setting exists to suppress. What survives is
           the meaning — the label is intermittent — carried by a slow breath
           between full and 42% over twice the cycle. */
        .glitch::before,
        .glitch::after {
          display: none;
        }

        .glitch {
          animation-name: fade-rm;
          animation-duration: calc(var(--t) * 2);
          animation-timing-function: var(--ease-in-out);
        }

        @keyframes fade-rm {
          0%,
          100% {
            opacity: 1;
          }
          50% {
            opacity: 0.42;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="eyebrow">Websocket</p>
      <p class="glitch" data-text="RECONNECTING">RECONNECTING</p>
      <p class="meta">attempt 3 · next retry in 4s</p>
    </div>
  </body>
</html>

The variant family

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