Back to the library

Text reveal — character by character

Variant: Character

Fifty-six glyphs at 12ms each — under one frame at 60fps, so neighbours land together and it reads as a wave rather than a queue. It still takes 660ms to seat the last character, and that number is the argument: per-character is a headline treatment, because on a paragraph it makes the reader wait on the animation before they can read. Every glyph is its own inline-block, which costs kerning and hands the compositor 56 layers.

textentrancestagger

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 reveal — character by character</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;
        --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 — every duration in the document runs off --t. */
        --cycle: 6s;
        --t: calc(var(--cycle) / var(--speed));

        /* Asymmetric: off the mark immediately, then decelerating for most of
           its length. Type has to look like it settled onto the baseline
           rather than stopped at it. */
        --ease-accent: cubic-bezier(0.2, 0.72, 0.12, 1);

        /* Travel, not a duration — scales with the type rather than the clock. */
        --_rise: clamp(5px, 1.3vw, 12px);
        --_step: 0.012s;
      }

      * {
        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);
      }

      .reveal {
        width: fit-content;
        max-width: 100%;
        animation: cycle var(--t) linear infinite;
      }

      /* The block is dark for the first 13% of every cycle. That window is not
         a pause — it is where the units snap back to their start pose out of
         sight, so the cascade never plays in reverse. It has to outlast the
         longest stagger in the family (660ms), and every sibling carries the
         same 13% so the four can be compared frame for frame. The exit is one
         uniform fade: a staggered exit would be a second performance. */
      @keyframes cycle {
        0%,
        13% {
          opacity: 0;
        }
        14%,
        95% {
          opacity: 1;
          animation-timing-function: ease;
        }
        98%,
        100% {
          opacity: 0;
        }
      }

      .copy {
        display: grid;
        justify-items: start;
      }

      .line {
        /* Lines are hard-broken, not wrapped, so all four siblings break in
           exactly the same places at every stage width. */
        white-space: nowrap;
      }

      .h {
        font-size: clamp(19px, 4.5vw, 34px);
        font-weight: 600;
        letter-spacing: -0.02em;
        line-height: 1.16;
      }

      .accent {
        color: var(--clay);
      }

      .b {
        font-size: clamp(12.5px, 2.95vw, 22px);
        line-height: 1.42;
        color: color-mix(in srgb, var(--slate) 58%, transparent);
      }

      .h + .b {
        margin-top: clamp(8px, 1.2vw, 14px);
      }

      .c {
        /* Transforms need a block-level box; inline boxes ignore them. */
        display: inline-block;
        opacity: 0;
        transform: translateY(var(--_rise));
        animation: rise var(--t) linear infinite;
        animation-delay: calc(var(--_i) * var(--_step) / var(--speed));
      }

      /* 12ms a glyph is under one frame at 60fps, so neighbours land
         together and the cascade reads as a wave rather than a queue. It still
         takes 660ms to seat the last of 56 characters — the reason this is a
         headline treatment and not a paragraph one. */
      @keyframes rise {
        0%,
        15% {
          opacity: 0;
          transform: translateY(var(--_rise));
          animation-timing-function: var(--ease-accent);
        }
        21%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent. The travel goes; the stagger stays, because the
           order things arrive in is meaning rather than decoration. The cycle
           stretches 1.7x, which only widens the reset window the offsets have
           to fit inside. */
        .reveal {
          animation-duration: calc(var(--t) * 1.7);
        }

        .c {
          transform: none;
          animation-name: rise-rm;
          animation-duration: calc(var(--t) * 1.7);
        }

        @keyframes rise-rm {
          0%,
          15% {
            opacity: 0;
            animation-timing-function: ease;
          }
          21%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="reveal">
      <div
        class="copy"
        role="img"
        aria-label="Every deploy, in one timeline. Builds, migrations and incidents."
      >
        <p class="line h">
          <span class="c" style="--_i: 0">E</span><span class="c" style="--_i: 1">v</span
          ><span class="c" style="--_i: 2">e</span><span class="c" style="--_i: 3">r</span
          ><span class="c" style="--_i: 4">y</span> <span class="c" style="--_i: 5">d</span
          ><span class="c" style="--_i: 6">e</span><span class="c" style="--_i: 7">p</span
          ><span class="c" style="--_i: 8">l</span><span class="c" style="--_i: 9">o</span
          ><span class="c" style="--_i: 10">y</span><span class="c" style="--_i: 11">,</span>
        </p>
        <p class="line h accent">
          <span class="c" style="--_i: 12">i</span><span class="c" style="--_i: 13">n</span>
          <span class="c" style="--_i: 14">o</span><span class="c" style="--_i: 15">n</span
          ><span class="c" style="--_i: 16">e</span> <span class="c" style="--_i: 17">t</span
          ><span class="c" style="--_i: 18">i</span><span class="c" style="--_i: 19">m</span
          ><span class="c" style="--_i: 20">e</span><span class="c" style="--_i: 21">l</span
          ><span class="c" style="--_i: 22">i</span><span class="c" style="--_i: 23">n</span
          ><span class="c" style="--_i: 24">e</span><span class="c" style="--_i: 25">.</span>
        </p>
        <p class="line b">
          <span class="c" style="--_i: 26">B</span><span class="c" style="--_i: 27">u</span
          ><span class="c" style="--_i: 28">i</span><span class="c" style="--_i: 29">l</span
          ><span class="c" style="--_i: 30">d</span><span class="c" style="--_i: 31">s</span
          ><span class="c" style="--_i: 32">,</span> <span class="c" style="--_i: 33">m</span
          ><span class="c" style="--_i: 34">i</span><span class="c" style="--_i: 35">g</span
          ><span class="c" style="--_i: 36">r</span><span class="c" style="--_i: 37">a</span
          ><span class="c" style="--_i: 38">t</span><span class="c" style="--_i: 39">i</span
          ><span class="c" style="--_i: 40">o</span><span class="c" style="--_i: 41">n</span
          ><span class="c" style="--_i: 42">s</span>
        </p>
        <p class="line b">
          <span class="c" style="--_i: 43">a</span><span class="c" style="--_i: 44">n</span
          ><span class="c" style="--_i: 45">d</span> <span class="c" style="--_i: 46">i</span
          ><span class="c" style="--_i: 47">n</span><span class="c" style="--_i: 48">c</span
          ><span class="c" style="--_i: 49">i</span><span class="c" style="--_i: 50">d</span
          ><span class="c" style="--_i: 51">e</span><span class="c" style="--_i: 52">n</span
          ><span class="c" style="--_i: 53">t</span><span class="c" style="--_i: 54">s</span
          ><span class="c" style="--_i: 55">.</span>
        </p>
      </div>
    </div>
  </body>
</html>

The variant family

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