Copied!
Text

Kinetic type reveals

Nine ways for a line of type to arrive, from scramble and split-flap to ink underline and 3D flip, picked from one menu and replayed each time it scrolls into view.

← Library

Element · Vanilla JS + CSS + SVGUpdated November 6, 2026

TextElementVanilla JS + CSS + SVG

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Kinetic type reveals" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-reveals.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-reveals.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- effect: scramble
- text: SET IN MOTION
- speed: 1
- size: 42
- color: #fbf8f1
- accent: #e6ff55
- ghost: #e54c1e
- background: #0c1e18
- label: 1

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/kinetic-type-reveals.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-ktr" data-fx="scramble" data-label="1">
  <div class="ef-ktr__head">
    <span class="ef-ktr__num" aria-hidden="true">01</span>
    <span class="ef-ktr__meta"><span class="ef-ktr__name">Scramble</span><span class="ef-ktr__desc">Noise resolves into signal, left to right.</span></span>
    <span class="ef-ktr__tag">auto · repeats on view</span>
  </div>
  <div class="ef-ktr__stage"><span class="ef-ktr__demo">SET IN MOTION</span></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-ktr {
  --ef-ktr-k: 1; --ef-ktr-size: 42;
  --ef-ktr-fg: #fbf8f1; --ef-ktr-fg-rgb: 251, 248, 241; --ef-ktr-muted: #9ca39b;
  --ef-ktr-bg: #0c1e18; --ef-ktr-bg-rgb: 12, 30, 24; --ef-ktr-top: #17281f;
  --ef-ktr-acc: #e6ff55; --ef-ktr-acc-rgb: 230, 255, 85; --ef-ktr-ghost: #e54c1e;
  --ef-ktr-fs: min(calc(var(--ef-ktr-size) * 1px), max(1.5rem, 9cqw));
  position: relative; box-sizing: border-box; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem;
  display: flex; flex-direction: column; container-type: inline-size;
  color: var(--ef-ktr-fg); font-family: 'Instrument Sans', system-ui, sans-serif;
  background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-ktr-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-ktr-top), var(--ef-ktr-bg));
  border: 1px solid rgba(var(--ef-ktr-acc-rgb), .2);
  box-shadow: inset 0 1px 0 rgba(var(--ef-ktr-fg-rgb), .05);
}
.ef-ktr *, .ef-ktr *::before, .ef-ktr *::after { box-sizing: border-box; }

/* specimen label */
.ef-ktr .ef-ktr__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }
.ef-ktr[data-label="0"] .ef-ktr__head { display: none; }
.ef-ktr .ef-ktr__num {
  flex-shrink: 0; width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
  font: 600 .78rem/1 'Fira Code', ui-monospace, monospace; color: var(--ef-ktr-bg); background: var(--ef-ktr-acc);
  box-shadow: 0 0 0 1px rgba(var(--ef-ktr-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-ktr-acc-rgb), .55);
}
.ef-ktr .ef-ktr__meta { display: block; min-width: 0; text-align: left; }
.ef-ktr .ef-ktr__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-ktr-fg); opacity: 1; }
.ef-ktr .ef-ktr__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-ktr-muted); opacity: 1; }
.ef-ktr .ef-ktr__tag {
  margin-left: auto; align-self: center; white-space: nowrap; padding: .3rem .6rem; border-radius: 20px;
  font: .62rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase;
  color: rgba(var(--ef-ktr-acc-rgb), .85); border: 1px solid rgba(var(--ef-ktr-acc-rgb), .35); background: rgba(var(--ef-ktr-acc-rgb), .06);
}
.ef-ktr.is-narrow .ef-ktr__tag, .ef-ktr.is-narrow .ef-ktr__desc { display: none; }

/* stage and type */
.ef-ktr .ef-ktr__stage { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2rem 1.5rem; min-height: 12rem; }
.ef-ktr .ef-ktr__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.ef-ktr .ef-ktr__demo {
  display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-ktr-fg); opacity: 1;
  font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-ktr-fs); letter-spacing: .01em; line-height: 1.2;
}
.ef-ktr .ef-ktr__demo--small { font-family: 'Fira Code', ui-monospace, monospace; font-size: calc(var(--ef-ktr-fs) * .615); }
.ef-ktr .ef-ktr__w { display: inline-block; white-space: nowrap; }
.ef-ktr .ef-ktr__ch { display: inline-block; will-change: transform, opacity, filter; }

/* 02 typewriter and 03 split-flap */
.ef-ktr .ef-ktr__type { border-right: 2px solid var(--ef-ktr-acc); padding-right: 4px; }
.ef-ktr .ef-ktr__ch.is-flap { border-bottom: 1px solid rgba(var(--ef-ktr-fg-rgb), .2); }

/* 04 stagger fade */
.ef-ktr[data-fx="stagger"] .ef-ktr__ch {
  opacity: 0; transform: translateY(.524em); /* 22px at the default 42px size */
  transition: opacity calc(.5s * var(--ef-ktr-k)) ease, transform calc(.5s * var(--ef-ktr-k)) ease;
  transition-delay: calc(var(--d, 0ms) * var(--ef-ktr-k));
}
.ef-ktr[data-fx="stagger"].is-in .ef-ktr__ch { opacity: 1; transform: translateY(0); }

/* 05 blur focus */
.ef-ktr[data-fx="blur"] .ef-ktr__ch {
  opacity: 0; filter: blur(14px); transform: scale(1.06);
  transition: opacity calc(.6s * var(--ef-ktr-k)) ease, filter calc(.6s * var(--ef-ktr-k)) ease, transform calc(.6s * var(--ef-ktr-k)) ease;
  transition-delay: calc(var(--d, 0ms) * var(--ef-ktr-k));
}
.ef-ktr[data-fx="blur"].is-in .ef-ktr__ch { opacity: 1; filter: blur(0); transform: scale(1); }

/* 06 glitch split */
.ef-ktr .ef-ktr__glitch { position: relative; }
.ef-ktr .ef-ktr__glitch::before, .ef-ktr .ef-ktr__glitch::after { content: attr(data-text); position: absolute; left: 0; top: 0; width: 100%; overflow: hidden; opacity: .75; }
.ef-ktr .ef-ktr__glitch::before { color: var(--ef-ktr-ghost); transform: translate(-2px, 0); mix-blend-mode: screen; animation: ef-ktr-gtop calc(2.6s * var(--ef-ktr-k)) infinite linear; }
.ef-ktr .ef-ktr__glitch::after { color: var(--ef-ktr-acc); transform: translate(2px, 0); mix-blend-mode: screen; animation: ef-ktr-gbot calc(2.9s * var(--ef-ktr-k)) infinite linear; }
.ef-ktr.is-paused .ef-ktr__glitch::before, .ef-ktr.is-paused .ef-ktr__glitch::after { animation-play-state: paused; }
@keyframes ef-ktr-gtop {
  0%, 89%, 100% { clip-path: inset(0 0 0 0); transform: translate(0, 0); }
  90% { clip-path: inset(10% 0 60% 0); transform: translate(-3px, -1px); }
  93% { clip-path: inset(60% 0 5% 0); transform: translate(3px, 1px); }
  96% { clip-path: inset(30% 0 40% 0); transform: translate(-2px, 0); }
}
@keyframes ef-ktr-gbot {
  0%, 89%, 100% { clip-path: inset(0 0 0 0); transform: translate(0, 0); }
  91% { clip-path: inset(70% 0 5% 0); transform: translate(3px, 0); }
  94% { clip-path: inset(15% 0 55% 0); transform: translate(-3px, 1px); }
  97% { clip-path: inset(45% 0 20% 0); transform: translate(2px, -1px); }
}

/* 07 ink signature */
.ef-ktr .ef-ktr__ink { display: inline-flex; flex-direction: column; align-items: center; gap: .4rem; }
.ef-ktr .ef-ktr__ink .ef-ktr__demo { opacity: 0; transition: opacity calc(.5s * var(--ef-ktr-k)) ease calc(.4s * var(--ef-ktr-k)); }
.ef-ktr.is-in .ef-ktr__ink .ef-ktr__demo { opacity: 1; }
.ef-ktr .ef-ktr__line { display: block; width: calc(var(--ef-ktr-fs) * 6.25); height: calc(var(--ef-ktr-fs) * .433); overflow: visible; color: var(--ef-ktr-acc); }
.ef-ktr .ef-ktr__line path { stroke-dasharray: 340; stroke-dashoffset: 340; transition: stroke-dashoffset calc(1s * var(--ef-ktr-k)) ease; }
.ef-ktr.is-in .ef-ktr__line path { stroke-dashoffset: 0; }

/* 08 curtain wipe */
.ef-ktr .ef-ktr__curtain { position: relative; display: inline-block; }
.ef-ktr .ef-ktr__bar { position: absolute; inset: 0; background: var(--ef-ktr-acc); transform-origin: left; transform: scaleX(1); transition: transform calc(.9s * var(--ef-ktr-k)) cubic-bezier(.6, 0, .15, 1); }
.ef-ktr.is-in .ef-ktr__bar { transform: scaleX(0); }

/* 09 3D flip-in */
.ef-ktr[data-fx="flip"] .ef-ktr__stage { perspective: 600px; }
.ef-ktr[data-fx="flip"] .ef-ktr__ch {
  opacity: 0; transform: rotateX(90deg) translateY(.143em); transform-origin: 50% 100%;
  transition: opacity calc(.45s * var(--ef-ktr-k)) ease, transform calc(.5s * var(--ef-ktr-k)) cubic-bezier(.2, .9, .3, 1.2);
  transition-delay: calc(var(--d, 0ms) * var(--ef-ktr-k));
}
.ef-ktr[data-fx="flip"].is-in .ef-ktr__ch { opacity: 1; transform: rotateX(0) translateY(0); }

/* reduced motion: finished frame, no transitions or loops */
.ef-ktr.is-reduced *, .ef-ktr.is-reduced *::before, .ef-ktr.is-reduced *::after { transition: none !important; animation: none !important; }
@media (prefers-reduced-motion: reduce) {
  .ef-ktr *, .ef-ktr *::before, .ef-ktr *::after { transition: none !important; animation: none !important; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Kinetic type reveals: the nine entrance studies from a kinetic type specimen book, one at a time (pick it with effect).
// Scramble: every 40 ms each unrevealed letter becomes a random glyph and every third tick one more letter locks in, left
// to right. Typewriter: one letter every 65 ms behind an accent cursor. Split-flap: each letter flips through random
// glyphs 8 to 16 times, ticking every 45 ms plus 4 ms per letter. Stagger fade: letters rise 22 px and fade in over 0.5 s,
// start times spread over 420 ms. Blur focus: letters go from a 14 px blur at 106% size to sharp over 0.6 s, same spread.
// Glitch split: two coloured copies flick sideways in clipped bands on a 2.6 s and a 2.9 s loop. Ink signature: an
// underline draws over 1 s while the word fades in after 0.4 s. Curtain wipe: an accent plate shrinks to the left over
// 0.9 s on cubic-bezier(.6,0,.15,1). 3D flip-in: letters swing down from a bottom hinge with a slight overshoot.
// Entrances play when the element comes into view and again each time it comes back; replay() plays them on demand.
// Speed scales every duration and tick. Reduced motion shows the finished text at once and the glitch copies stand still.
// Timers pause off screen and in hidden tabs.
// mount(root, { effect (scramble | typewriter | splitflap | stagger | blur | glitch | ink | curtain | flip), text,
//               speed (1 = original timing), size (px), color, accent, ghost (second glitch colour), background,
//               label (1 shows the specimen label, 0 hides it) })
function mount(root, o) {
  var reduce = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  var box = root.querySelector(".ef-ktr");
  var numEl = box.querySelector(".ef-ktr__num"), nameEl = box.querySelector(".ef-ktr__name");
  var descEl = box.querySelector(".ef-ktr__desc"), tagEl = box.querySelector(".ef-ktr__tag");
  var stage = box.querySelector(".ef-ktr__stage");
  var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*+-/<>[]{}01";
  var AUTO = "auto · repeats on view", LOOP = "continuous";
  var FX = {
    scramble: ["01", "Scramble", "Noise resolves into signal, left to right.", AUTO],
    typewriter: ["02", "Typewriter", "One keystroke at a time, cursor included.", AUTO],
    splitflap: ["03", "Split-flap", "Each letter clatters through its own departures board.", AUTO],
    stagger: ["04", "Stagger fade", "Letters rise into place, one beat after the last.", AUTO],
    blur: ["05", "Blur focus", "Out of soft focus, into sharpness.", AUTO],
    glitch: ["06", "Glitch split", "Two coloured ghosts drift from true position.", LOOP],
    ink: ["07", "Ink signature", "A stroke draws itself beneath the word.", AUTO],
    curtain: ["08", "Curtain wipe", "A solid plate slides clear to reveal the word beneath.", AUTO],
    flip: ["09", "3D flip-in", "Letters fold down from a hinge, like Rolodex cards.", AUTO]
  };
  var opts = {}, fx = "", text = "", demo = null, spans = [], inView = false, visible = true;
  var io = null, ro = null, destroyed = false, timers = [], clock = 0, last = 0, raf = 0;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(String(h || "")); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function mix(a, b, t) { return "rgb(" + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * t); }).join(",") + ")"; }
  function randInt(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
  function glyph() { return CHARS[randInt(0, CHARS.length - 1)]; }
  function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }

  // One clock for every tick: it only advances while the element is on screen and the tab is visible, scaled by speed.
  function tick(now) {
    raf = 0;
    var dt = Math.min(50, Math.max(0, now - last)); last = now;
    if (visible && !document.hidden) {
      clock += dt * opts.speed;
      var list = timers.slice();
      for (var i = 0; i < list.length; i++) {
        var t = list[i], n = 0;
        while (!t.dead && t.at <= clock && n++ < 4) { if (t.every) t.at += t.every; else t.dead = true; t.fn(t); }
      }
      timers = timers.filter(function (t) { return !t.dead; });
    }
    if (timers.length && !destroyed && visible) raf = requestAnimationFrame(tick); // off screen the loop stops; the observer restarts it
  }
  function kick() { if (!raf && !destroyed && timers.length) { last = performance.now(); raf = requestAnimationFrame(tick); } }
  function every(ms, fn) { var t = { at: clock + ms, every: ms, fn: fn }; timers.push(t); kick(); return t; }
  function stopTimers() { timers.forEach(function (t) { t.dead = true; }); timers = []; }

  // letters as aria-hidden spans grouped in unbreakable words; the index counts spaces, as in the original
  function split(max) {
    demo.textContent = ""; spans = [];
    var chars = Array.from(text), len = Math.max(chars.length - 1, 1), word = null;
    chars.forEach(function (ch, i) {
      if (/\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(" ")); return; }
      if (!word) { word = make("span", "ef-ktr__w"); demo.appendChild(word); }
      var s = make("span", "ef-ktr__ch", ch);
      s.style.setProperty("--i", i);
      if (max) s.style.setProperty("--d", (i / len * max).toFixed(0) + "ms");
      s._i = i; s._ch = ch;
      word.appendChild(s); spans.push(s);
    });
  }

  function build() {
    stopTimers();
    fx = FX[opts.effect] ? opts.effect : "scramble";
    var f = FX[fx];
    box.setAttribute("data-fx", fx);
    numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2]; tagEl.textContent = f[3];
    box.classList.remove("is-in");
    text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "SET IN MOTION";
    stage.textContent = "";
    stage.appendChild(make("span", "ef-ktr__sr", text));
    demo = make("span", "ef-ktr__demo"); demo.setAttribute("aria-hidden", "true");
    var holder = demo;
    if (fx === "typewriter") demo.className += " ef-ktr__type";
    if (fx === "splitflap") demo.className += " ef-ktr__demo--small";
    if (fx === "stagger" || fx === "blur" || fx === "flip") split(420);
    if (fx === "glitch") { demo.className += " ef-ktr__glitch"; demo.setAttribute("data-text", text); demo.textContent = text; }
    if (fx === "ink") {
      holder = make("span", "ef-ktr__ink"); holder.setAttribute("aria-hidden", "true");
      demo.textContent = text; holder.appendChild(demo);
      holder.insertAdjacentHTML("beforeend", '<svg class="ef-ktr__line" viewBox="0 0 260 18" focusable="false"><path d="M4 10 Q 60 2, 130 9 T 256 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>');
    }
    if (fx === "curtain") {
      holder = make("span", "ef-ktr__curtain"); holder.setAttribute("aria-hidden", "true");
      demo.textContent = text; holder.appendChild(demo); holder.appendChild(make("span", "ef-ktr__bar"));
    }
    stage.appendChild(holder);
    if (reduce) finish(); else if (inView) play();
  }

  function play() {
    if (reduce || destroyed) return;
    stopTimers();
    box.classList.remove("is-in");
    void box.offsetWidth; // commit the hidden state so the transitions run
    box.classList.add("is-in");
    var chars = Array.from(text);
    if (fx === "scramble") {
      var revealed = 0, frame = 0;
      every(40, function (t) {
        frame++;
        var out = "";
        for (var i = 0; i < chars.length; i++) {
          if (chars[i] === " ") { out += " "; continue; }
          out += i < revealed ? chars[i] : glyph();
        }
        demo.textContent = out;
        if (frame % 3 === 0) revealed++;
        if (revealed > chars.length) { t.dead = true; demo.textContent = text; }
      });
    } else if (fx === "typewriter") {
      var k = 0;
      demo.textContent = "";
      every(65, function (t) {
        demo.textContent = chars.slice(0, k + 1).join("");
        k++;
        if (k >= chars.length) t.dead = true;
      });
    } else if (fx === "splitflap") {
      split(0);
      spans.forEach(function (s) {
        var count = 0, total = randInt(8, 16);
        s.classList.add("is-flap");
        every(45 + s._i * 4, function (t) {
          count++;
          if (count >= total) { s.textContent = s._ch; t.dead = true; } else s.textContent = glyph();
        });
      });
    }
  }
  function finish() {
    box.classList.add("is-reduced", "is-in");
    if (fx === "scramble" || fx === "typewriter") demo.textContent = text;
    if (fx === "splitflap") { split(0); spans.forEach(function (s) { s.classList.add("is-flap"); }); }
  }
  function leave() { inView = false; stopTimers(); if (!reduce) box.classList.remove("is-in"); }
  function replay() { if (!reduce && !destroyed) play(); }

  function paint() {
    var fg = rgb(opts.color, "fbf8f1"), bg = rgb(opts.background, "0c1e18"), ac = rgb(opts.accent, "e6ff55"), s = box.style;
    s.setProperty("--ef-ktr-fg", "rgb(" + fg + ")"); s.setProperty("--ef-ktr-fg-rgb", fg.join(","));
    s.setProperty("--ef-ktr-bg", "rgb(" + bg + ")"); s.setProperty("--ef-ktr-bg-rgb", bg.join(","));
    s.setProperty("--ef-ktr-top", mix(bg, fg, 0.045)); s.setProperty("--ef-ktr-muted", mix(bg, fg, 0.62));
    s.setProperty("--ef-ktr-acc", "rgb(" + ac + ")"); s.setProperty("--ef-ktr-acc-rgb", ac.join(","));
    s.setProperty("--ef-ktr-ghost", "rgb(" + rgb(opts.ghost, "e54c1e") + ")");
    s.setProperty("--ef-ktr-k", String(1 / opts.speed));
    s.setProperty("--ef-ktr-size", String(opts.size));
    box.setAttribute("data-label", opts.label ? "1" : "0");
  }
  function update(partial) {
    var prev = opts;
    opts = Object.assign({ effect: "scramble", text: "SET IN MOTION", speed: 1, size: 42, color: "#fbf8f1", accent: "#e6ff55", ghost: "#e54c1e", background: "#0c1e18", label: 1 }, opts, partial || {});
    opts.speed = Math.min(5, Math.max(0.1, num(opts.speed, 1)));
    opts.size = Math.max(10, num(opts.size, 42));
    opts.label = !(opts.label === 0 || opts.label === "0" || opts.label === false || opts.label === "off");
    paint();
    if (destroyed) return;
    if (!fx || prev.effect !== opts.effect || prev.text !== opts.text) build();
  }

  update(o);
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (es) {
      es.forEach(function (e) {
        visible = e.isIntersecting;
        box.classList.toggle("is-paused", !visible);
        var vh = e.rootBounds ? e.rootBounds.height : window.innerHeight;
        if (e.isIntersecting && (e.intersectionRatio >= 0.35 || e.boundingClientRect.height > vh * 0.8)) {
          if (!inView) { inView = true; play(); }
        } else if (!e.isIntersecting && inView) leave();
        if (visible) kick();
      });
    }, { threshold: [0, 0.35] });
    io.observe(box);
  } else { inView = true; play(); }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () { if (!destroyed) box.classList.toggle("is-narrow", box.clientWidth < 520); });
    ro.observe(box);
  }
  function onVis() { if (!document.hidden) kick(); }
  document.addEventListener("visibilitychange", onVis);
  return {
    update: update,
    replay: replay,
    destroy: function () {
      destroyed = true; stopTimers(); cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"effect":"scramble","text":"SET IN MOTION","speed":1,"size":42,"color":"#fbf8f1","accent":"#e6ff55","ghost":"#e54c1e","background":"#0c1e18","label":1});

How it works

Each study is a plate from a kinetic type specimen book, and the Study menu swaps which one plays. Scramble, typewriter and split-flap rewrite the text on a timer: scramble ticks every 40 ms and locks one more letter every third tick, typewriter adds a letter every 65 ms, and split-flap gives every letter its own 8 to 16 random flips, ticking every 45 ms plus 4 ms per letter. Stagger fade, blur focus and 3D flip-in wrap each letter in a span with a delay spread over 420 ms and let CSS transitions do the rest (0.5 s rise, 0.6 s blur to sharp, a 0.5 s hinge swing with a small overshoot). Glitch split draws two coloured copies of the word with pseudo-elements and flicks them sideways in clipped bands on 2.6 s and 2.9 s loops. Ink signature draws an SVG underline with a stroke-dash transition over 1 s while the word fades in, and curtain wipe shrinks an accent plate to the left over 0.9 s. An IntersectionObserver plays the entrance when about a third of the element is on screen and resets it once it has left, so it plays again on the way back; the Library's Replay button calls replay(). Speed divides every duration and tick.

Accessibility and performance

The full line is in the element as visually hidden text and the animated letters are hidden from screen readers, so it is read once, normally, even while it scrambles. With prefers-reduced-motion the finished text shows at once, the glitch copies stand still and nothing ticks. Nothing needs keyboard input. Check your text and background colours for contrast if you change them.

Transitions only touch opacity, transform and filter on a few dozen spans. The text-rewriting studies share one requestAnimationFrame clock that stops when there is nothing left to tick, pauses off screen and in hidden tabs, and the glitch loop pauses off screen too.

SectionText
TechniquesText animation, Reveal and intro, Stagger, CSS 3D
StackVanilla JS, CSS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byText Animations by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Serif motion study heroA starlit green hero whose serif headline blurs into place with hand-drawn underlines, then opens onto an endless curved wall of cards you can drag through. Staggered grid revealA grid of tiles that flip up one after another in random, reading, centre-out or edges-in order, then spin their glyphs into place. Counting stats bandA stats band whose numbers count up from zero, rules draw under them and labels fade in one after another when it comes into view.

FAQ

Can I show just one study, without the label?

Yes. Pick the study, then set Specimen label to Hide. You get the plain stage with your text and the entrance.

How do I play it again on demand?

The mount function returns replay(). Call it from a button or when a slide becomes active; it resets the letters and plays the entrance from the start.

Does it need GSAP or SplitText?

No. The splits are small loops that wrap letters in spans, the timed studies run on one requestAnimationFrame clock, and the rest is CSS transitions with the original durations and curves.

Why does the typewriter grow from the centre?

The text is centred and the line gets longer as it types, exactly as in the original. Put the stage in a left-aligned container if you want it to grow to the right.