Typing Animations and Typewriter Effects

Updated 2026-10-14. 9 effects.

A pure CSS typing animation widens a box over the text with the steps() timing function, one step per character, while a border-right blinks as the caret. It needs a monospace font, or clip-path for proportional ones. For several phrases, typing and deleting, a few lines of JavaScript are simpler. Both kinds are below.

Choose CSS when the line is short, fixed and typed once, such as a hero tagline or a terminal prompt. Choose a script version when the text changes, when phrases cycle, or when you want natural rhythm: slightly uneven key timing and a longer pause after punctuation read as a person typing rather than a machine.

Screen readers should not hear a sentence assembled letter by letter. Each effect puts the complete text in a visually hidden element once and hides the typed copy from assistive technology. The box is sized to the longest phrase so the page does not jump as the line grows and shrinks.

Compare them

EffectBuilt withSize (gzipped)Running cost
Classic CSS Typewriter LineCSS only1.5 KBTwo CSS animations on one line of text, a width change and an opacity blink; no JavaScript runs except an optional off-screen pause.
Clip-Path Typewriter for Serif HeadlinesCSS only1.9 KBAbout thirty tiny clip-path animations that each run once for 90ms, then only one blinking caret; no JavaScript is needed.
Code Snippet Typing with Syntax ColoursCSS only2.2 KBRebuilds a few dozen coloured spans on each keystroke, a tiny DOM update, and pauses off screen.
Search Box with Typing PlaceholderCSS only2 KBChanges one placeholder attribute on a timer and halts entirely while the field is focused, filled or scrolled away.
Shop Support Reply Typed Word by WordCSS only2.2 KBAppends one span per word with a short opacity and blur fade, around thirty in a cycle, and waits while off screen.
Terminal Command TypewriterCSS only1.8 KBSix looping CSS animations on text (one width, five opacity) and no script beyond an optional off-screen pause.
Type and Delete Loop with Natural RhythmCSS only2 KBOne text node updated by a timer roughly every 50ms while typing; the loop waits whenever the headline is off screen.
Headline That Retypes One WordCSS only2 KBEdits a single text node and one data attribute on timers; nothing runs while the headline is off screen.
Typewritten Letter on PaperCSS only2.3 KBAdds one small span per character on a timer, a few hundred at most, and stops while the letter is off screen.

Classic CSS Typewriter Line

A single line of monospace type that writes itself out, holds, erases and starts again, driven only by a width animation in ch units and the steps() timing function. A block caret blinks at the end of the line. The box is sized to the finished sentence, so the button and copy below never move, and the font scales with its container so the line fits a 360 pixel phone.

<!-- Classic CSS typewriter: one line, steps() + ch widths, monospace font. -->
<section class="ctw-root" data-paused="false">
  <style>
    /* Values to change:
       --ctw-n      number of characters in the line (count spaces and the full stop). Also update both steps(35) below.
       --ctw-ink    text colour
       --ctw-caret  caret colour
       --ctw-cycle  full loop length (type, hold, erase) */
    .ctw-root {
      --ctw-n: 35; --ctw-ink: #1e293b; --ctw-caret: #c2410c; --ctw-cycle: 7s;
      container-type: inline-size; background: #ffffff; padding: 96px 16px; text-align: center;
      font-family: ui-sans-serif, system-ui, sans-serif;
    }
    .ctw-root * { box-sizing: border-box; }
    .ctw-kicker { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: 0.2em; text-transform: uppercase; color: #475569; }
    .ctw-h { margin: 24px 0 0; font-weight: inherit; font-size: inherit; }
    .ctw-line {
      display: inline-block; position: relative; white-space: nowrap; text-align: left;
      font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
      font-size: min(2.75rem, 4.3cqi); font-weight: 600; color: var(--ctw-ink);
      width: calc(var(--ctw-n) * 1ch + 0.6ch);
    }
    .ctw-typed {
      display: inline-block; overflow: hidden; vertical-align: bottom;
      width: calc(var(--ctw-n) * 1ch);
      animation: ctw-type var(--ctw-cycle) infinite; animation-delay: -1.3s;
    }
    .ctw-caret {
      display: inline-block; width: 0.55ch; height: 1.1em; margin-left: 0.05ch; vertical-align: -0.18em;
      background: var(--ctw-caret); animation: ctw-blink 1s steps(1) infinite;
    }
    @keyframes ctw-type {
      0% { width: 0; animation-timing-function: steps(35, end); }
      42% { width: calc(var(--ctw-n) * 1ch); animation-timing-function: linear; }
      86% { width: calc(var(--ctw-n) * 1ch); animation-timing-function: steps(35, end); }
      98%, 100% { width: 0; }
    }
    @keyframes ctw-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .ctw-root[data-paused="true"] .ctw-typed, .ctw-root[data-paused="true"] .ctw-caret { animation-play-state: paused; }
    .ctw-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    .ctw-copy { max-width: 28rem; margin: 24px auto 0; font-size: 16px; line-height: 1.5; color: #475569; }
    .ctw-btn { display: inline-flex; align-items: center; height: 44px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: #0f172a; color: #fff; font-size: 14px; font-weight: 600; text-decoration: none; }
    .ctw-btn:focus-visible { outline: 2px solid var(--ctw-caret); outline-offset: 3px; }
    @media (prefers-reduced-motion: reduce) { .ctw-typed, .ctw-caret { animation: none; } }
  </style>
  <p class="ctw-kicker">Marlow Lane Bakery</p>
  <h1 class="ctw-h">
    <span class="ctw-sr">Open until the last loaf sells out.</span>
    <span class="ctw-line" aria-hidden="true"><span class="ctw-typed">Open until the last loaf sells out.</span><span class="ctw-caret"></span></span>
  </h1>
  <p class="ctw-copy">Doors at seven, rye on Tuesdays, and the oven stays warm until the shelves are empty.</p>
  <a class="ctw-btn" href="#">See today's bake</a>
  <script>
    /* Optional: pauses the loop while the line is off screen. */
    (function () {
      var root = document.currentScript.parentNode;
      if (!("IntersectionObserver" in window)) return;
      new IntersectionObserver(function (entries) {
        root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
      }).observe(root);
    })();
  </script>
</section>

With reduced motion: The whole sentence is shown at once with a solid caret and no blinking.

Running cost: Two CSS animations on one line of text, a width change and an opacity blink; no JavaScript runs except an optional off-screen pause.

Full page for Classic CSS Typewriter Line

Clip-Path Typewriter for Serif Headlines

A two-line serif headline for a florist that types itself in with no script. Each letter is unmasked by its own clip-path on a staggered delay, which keeps the real kerning of a proportional font, and a thin rose caret hops from letter to letter before blinking at the full stop. A Replay button restarts it.

<!-- Clip-path typewriter: pure CSS, works with proportional fonts. Each letter has its own --i index. -->
<section class="cpt-root" data-paused="false">
  <style>
    /* Values to change:
       --cpt-step   time between letters
       --cpt-ink    headline colour
       --cpt-caret  caret colour
       background on .cpt-root sets the dawn gradient */
    .cpt-root {
      --cpt-step: 45ms; --cpt-ink: #3b1d3a; --cpt-caret: #b4535a;
      background: linear-gradient(160deg, #fcebdc 0%, #f6e4e4 45%, #ece3f1 100%);
      padding: 96px 16px; text-align: center; font-family: ui-sans-serif, system-ui, sans-serif;
    }
    .cpt-wrap { max-width: 48rem; margin: 0 auto; }
    .cpt-kicker { margin: 0; font-size: 14px; font-weight: 500; color: #6b3a52; }
    .cpt-h { margin: 20px 0 0; font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; font-weight: 500; color: var(--cpt-ink); font-size: clamp(2.25rem, 6.5vw, 4.5rem); line-height: 1.08; letter-spacing: -0.01em; }
    .cpt-w { display: inline-block; white-space: nowrap; }
    .cpt-c { position: relative; display: inline-block; }
    .cpt-g { display: inline-block; clip-path: inset(0 100% 0 0); animation: cpt-in 90ms ease-out both; animation-delay: calc(var(--i) * var(--cpt-step)); }
    .cpt-c::after { content: ""; position: absolute; right: -0.07em; top: 0.12em; bottom: 0.08em; width: 0.06em; min-width: 2px; background: var(--cpt-caret); opacity: 0; animation: cpt-caret var(--cpt-step) steps(1); animation-delay: calc(var(--i) * var(--cpt-step)); }
    .cpt-c.cpt-last::after { animation: cpt-blink 1.05s steps(1) infinite; animation-delay: calc(var(--i) * var(--cpt-step)); }
    @keyframes cpt-in { to { clip-path: inset(0 -0.2em 0 -0.2em); } }
    @keyframes cpt-caret { 0%, 100% { opacity: 1; } }
    @keyframes cpt-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .cpt-root[data-paused="true"] .cpt-g, .cpt-root[data-paused="true"] .cpt-c::after { animation-play-state: paused; }
    .cpt-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    .cpt-copy { max-width: 28rem; margin: 24px auto 0; font-size: 16px; line-height: 1.6; color: #5b3a4f; }
    .cpt-actions { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 32px; }
    .cpt-btn { display: inline-flex; align-items: center; height: 44px; padding: 0 24px; border-radius: 999px; background: var(--cpt-ink); color: #fff; font: 600 14px/1 inherit; font-family: inherit; text-decoration: none; border: 1px solid transparent; cursor: pointer; }
    .cpt-btn.cpt-ghost { background: transparent; color: var(--cpt-ink); border-color: rgba(59, 29, 58, 0.3); font-weight: 500; }
    .cpt-btn:focus-visible { outline: 2px solid var(--cpt-caret); outline-offset: 3px; }
    @media (prefers-reduced-motion: reduce) { .cpt-g { animation: none; clip-path: none; } .cpt-c::after { animation: none; } }
  </style>
  <div class="cpt-wrap">
    <p class="cpt-kicker">Petal &amp; Fern, Canal Street</p>
    <h1 class="cpt-h">
      <span class="cpt-sr">Flowers that last the whole week.</span>
      <span class="cpt-anim" aria-hidden="true">
      <span class="cpt-w"><span class="cpt-c" style="--i:0"><span class="cpt-g">F</span></span><span class="cpt-c" style="--i:1"><span class="cpt-g">l</span></span><span class="cpt-c" style="--i:2"><span class="cpt-g">o</span></span><span class="cpt-c" style="--i:3"><span class="cpt-g">w</span></span><span class="cpt-c" style="--i:4"><span class="cpt-g">e</span></span><span class="cpt-c" style="--i:5"><span class="cpt-g">r</span></span><span class="cpt-c" style="--i:6"><span class="cpt-g">s</span></span></span> 
      <span class="cpt-w"><span class="cpt-c" style="--i:8"><span class="cpt-g">t</span></span><span class="cpt-c" style="--i:9"><span class="cpt-g">h</span></span><span class="cpt-c" style="--i:10"><span class="cpt-g">a</span></span><span class="cpt-c" style="--i:11"><span class="cpt-g">t</span></span></span> 
      <span class="cpt-w"><span class="cpt-c" style="--i:13"><span class="cpt-g">l</span></span><span class="cpt-c" style="--i:14"><span class="cpt-g">a</span></span><span class="cpt-c" style="--i:15"><span class="cpt-g">s</span></span><span class="cpt-c" style="--i:16"><span class="cpt-g">t</span></span></span> 
      <span class="cpt-w"><span class="cpt-c" style="--i:18"><span class="cpt-g">t</span></span><span class="cpt-c" style="--i:19"><span class="cpt-g">h</span></span><span class="cpt-c" style="--i:20"><span class="cpt-g">e</span></span></span> 
      <span class="cpt-w"><span class="cpt-c" style="--i:22"><span class="cpt-g">w</span></span><span class="cpt-c" style="--i:23"><span class="cpt-g">h</span></span><span class="cpt-c" style="--i:24"><span class="cpt-g">o</span></span><span class="cpt-c" style="--i:25"><span class="cpt-g">l</span></span><span class="cpt-c" style="--i:26"><span class="cpt-g">e</span></span></span> 
      <span class="cpt-w"><span class="cpt-c" style="--i:28"><span class="cpt-g">w</span></span><span class="cpt-c" style="--i:29"><span class="cpt-g">e</span></span><span class="cpt-c" style="--i:30"><span class="cpt-g">e</span></span><span class="cpt-c" style="--i:31"><span class="cpt-g">k</span></span><span class="cpt-c cpt-last" style="--i:32"><span class="cpt-g">.</span></span></span>
      </span>
    </h1>
    <p class="cpt-copy">Stems cut at dawn from two farms up the valley, wrapped in paper and delivered before lunch.</p>
    <div class="cpt-actions">
      <a class="cpt-btn" href="#">Order a bunch</a>
      <button class="cpt-btn cpt-ghost" type="button">Replay typing</button>
    </div>
  </div>
  <script>
    /* Optional: pauses while off screen, and the Replay button restarts the letters. */
    (function () {
      var root = document.currentScript.parentNode;
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
        }).observe(root);
      }
      root.querySelector(".cpt-ghost").addEventListener("click", function () {
        var anim = root.querySelector(".cpt-anim");
        anim.parentNode.replaceChild(anim.cloneNode(true), anim);
      });
    })();
  </script>
</section>

With reduced motion: The headline is fully visible from the start and the caret stays hidden.

Running cost: About thirty tiny clip-path animations that each run once for 90ms, then only one blinking caret; no JavaScript is needed.

Code Snippet Typing with Syntax Colours

An editor pane in deep ink blue where a short JavaScript helper writes itself line by line. The snippet is stored as coloured tokens, so a keyword is amber and a string is sage from its first letter, and the caret pauses after brackets and line ends the way a developer does. Line numbers and the full code are laid out up front.

<!-- Code snippet that types itself with syntax colours. Edit TOKENS in the script. -->
<section class="cst-root">
  <style>
    /* Values to change:
       .cst-k / .cst-f / .cst-s / .cst-c   keyword, function, string and comment colours
       .cst-caret background               caret colour
       START_AT and gapAfter() in the script   where typing starts and how fast */
    .cst-root { padding: 64px 16px; font-family: ui-sans-serif, system-ui, sans-serif; }
    .cst-root * { box-sizing: border-box; }
    .cst-root { background: linear-gradient(180deg, #0d1219 0%, #111a24 100%); }
    .cst-win { max-width: 44rem; margin: 0 auto; border-radius: 12px; background: #151c25; border: 1px solid #243040; box-shadow: 0 30px 70px -30px rgba(0,0,0,0.7); overflow: hidden; }
    .cst-tab { display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 16px; background: #10161e; border-bottom: 1px solid #243040; font-size: 12px; color: #9aa7b6; }
    .cst-tab b { font-weight: 500; color: #d7dde5; }
    .cst-code { display: grid; grid-template-columns: auto 1fr; gap: 0 16px; padding: 20px 16px 24px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: clamp(10.5px, 2.8vw, 15px); line-height: 1.75; }
    .cst-nums { color: #5d6b7c; text-align: right; user-select: none; white-space: pre; }
    .cst-stack { display: grid; min-width: 0; overflow-x: auto; }
    .cst-stack > * { grid-area: 1 / 1; white-space: pre; }
    .cst-ghost { visibility: hidden; }
    .cst-c { color: #8996a5; font-style: italic; } .cst-k { color: #d8a26a; } .cst-f { color: #85b6dc; } .cst-v { color: #e2d6bf; } .cst-s { color: #a3c79a; } .cst-p { color: #b6c0cc; } .cst-t { color: inherit; }
    .cst-caret { display: inline-block; width: 2px; height: 1.2em; vertical-align: -0.25em; background: #e8b77a; animation: cst-blink 1s steps(1) infinite; }
    @keyframes cst-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .cst-note { max-width: 44rem; margin: 18px auto 0; text-align: center; font-size: 14px; color: #a9b4c1; }
    @media (prefers-reduced-motion: reduce) { .cst-caret { animation: none; } }
    .cst-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
  </style>
  <h2 class="cst-sr">Code example: opening hours function</h2>
  <pre class="cst-sr cst-full"></pre>
  <div class="cst-win" aria-hidden="true">
    <div class="cst-tab"><b>hours.js</b><span>footer helpers</span></div>
    <div class="cst-code">
      <div class="cst-nums"></div>
      <div class="cst-stack">
        <div class="cst-ghost"></div>
        <div class="cst-live"><span class="cst-caret"></span></div>
      </div>
    </div>
  </div>
  <p class="cst-note">Each token keeps its colour while it is being typed.</p>
  <script>
    (function () {
      var TOKENS = [["c", "// Opening hours for the shop footer"], ["n", "\n"], ["k", "export function "], ["f", "hoursFor"], ["p", "(day) {"], ["n", "\n"], ["t", "  "], ["k", "const "], ["v", "week"], ["p", " = { "], ["v", "sat"], ["p", ": "], ["s", "\"9-4\""], ["p", ", "], ["v", "sun"], ["p", ": "], ["s", "\"shut\""], ["p", " };"], ["n", "\n"], ["t", "  "], ["k", "return "], ["v", "week"], ["p", "[day] ?? "], ["s", "\"8-6\""], ["p", ";"], ["n", "\n"], ["p", "}"], ["n", "\n"], ["n", "\n"], ["v", "footer"], ["p", "."], ["f", "textContent"], ["p", " = "], ["f", "hoursFor"], ["p", "("], ["s", "\"sat\""], ["p", ");"]];
      var FULL = TOKENS.map(function (t) { return t[1]; }).join("");
      var START_AT = Math.floor(FULL.length * 0.55);
      var root = document.currentScript.parentNode;
      var live = root.querySelector(".cst-live");
      var caret = root.querySelector(".cst-caret");
      root.querySelector(".cst-full").textContent = FULL;
      root.querySelector(".cst-ghost").textContent = FULL;
      var nums = [];
      for (var l = 1; l <= FULL.split("\n").length; l++) nums.push(l);
      root.querySelector(".cst-nums").textContent = nums.join("\n");

      function render(n) {
        while (live.firstChild !== caret) live.removeChild(live.firstChild);
        var left = n;
        for (var i = 0; i < TOKENS.length && left > 0; i++) {
          var s = document.createElement("span");
          s.className = "cst-" + TOKENS[i][0];
          s.textContent = TOKENS[i][1].slice(0, left);
          live.insertBefore(s, caret);
          left -= TOKENS[i][1].length;
        }
      }
      function gapAfter(ch) {
        if (ch === "\n") return 260;
        if (ch === " ") return 40 + Math.random() * 50;
        if ("({;".indexOf(ch) > -1) return 120 + Math.random() * 80;
        return 22 + Math.random() * 30;
      }
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { render(FULL.length); return; }
      render(START_AT);
      var visible = true, resume = null;
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (visible) go(); else resume = go;
        });
      }
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && resume) { var r = resume; resume = null; r(); }
        }).observe(root);
      }
      async function loop() {
        var n = START_AT;
        for (;;) {
          while (n < FULL.length) { await wait(gapAfter(FULL[n - 1] || "")); n++; render(n); }
          await wait(3200);
          n = 0; render(0);
          await wait(600);
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: The whole snippet is shown coloured and complete, with the caret resting after the last line.

Running cost: Rebuilds a few dozen coloured spans on each keystroke, a tiny DOM update, and pauses off screen.

Search Box with Typing Placeholder

A hardware shop search field on a sea glass gradient whose placeholder types out example searches, clears them and tries the next. It stops the moment someone clicks into the field or starts typing, swapping in a plain hint, and picks up again after they leave it empty. The input is a real labelled search field, so it works with no script at all.

<!-- Search box whose placeholder types product suggestions. Stops while the visitor is focused or has typed. -->
<section class="sph-root">
  <style>
    /* Values to change:
       --sph-ink    heading and button colour
       --sph-hint   placeholder colour (keep 4.5:1 on white)
       SUGGESTIONS and STATIC_HINT in the script */
    .sph-root { --sph-ink: #12403a; --sph-hint: #4b6b66; background: linear-gradient(#e3f0ec, #cfe4de); padding: 80px 16px; font-family: ui-sans-serif, system-ui, sans-serif; text-align: center; }
    .sph-root * { box-sizing: border-box; }
    .sph-wrap { max-width: 36rem; margin: 0 auto; }
    .sph-h { margin: 0; font-family: Georgia, serif; font-weight: 500; font-size: clamp(1.9rem, 5vw, 2.25rem); color: var(--sph-ink); }
    .sph-sub { margin: 12px 0 0; color: #2f5a53; font-size: 16px; }
    .sph-form { margin-top: 32px; }
    .sph-box { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #9cc3b9; border-radius: 16px; background: #fff; box-shadow: 0 18px 40px -24px rgba(18, 64, 58, 0.45); }
    .sph-box:focus-within { border-color: var(--sph-ink); }
    .sph-icon { width: 20px; height: 20px; margin-left: 8px; flex-shrink: 0; color: #3f6b64; }
    .sph-input { flex: 1; min-width: 0; height: 44px; border: 0; background: transparent; font: inherit; font-size: 16px; color: var(--sph-ink); }
    .sph-input::placeholder { color: var(--sph-hint); opacity: 1; }
    .sph-input:focus { outline: none; }
    .sph-btn { height: 44px; flex-shrink: 0; padding: 0 16px; border: 0; border-radius: 12px; background: var(--sph-ink); color: #fff; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }
    .sph-btn:focus-visible { outline: 2px solid var(--sph-ink); outline-offset: 2px; }
    .sph-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    .sph-pop { margin: 16px 0 0; font-size: 14px; color: #2f5a53; }
    @media (prefers-reduced-motion: reduce) { .sph-input { transition: none; } }
  </style>
  <div class="sph-wrap">
    <h2 class="sph-h">Pennick &amp; Sons Hardware</h2>
    <p class="sph-sub">Four floors of fittings, finishes and hand tools, all in stock.</p>
    <form class="sph-form" role="search">
      <label class="sph-sr" for="sph-q">Search the shop</label>
      <div class="sph-box">
        <svg class="sph-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>
        <input class="sph-input" id="sph-q" type="search" placeholder='Try: brass cabinet hinges'>
        <button class="sph-btn" type="submit">Search</button>
      </div>
    </form>
    <p class="sph-pop">Popular this week: hinges, beeswax, chisels</p>
  </div>
  <script>
    (function () {
      var SUGGESTIONS = ["brass cabinet hinges", "linseed oil for oak", "a cast iron door knocker", "wood glue that sets fast"];
      var STATIC_HINT = "Search hinges, oils, tools and more";
      var root = document.currentScript.parentNode;
      var input = root.querySelector(".sph-input");
      root.querySelector(".sph-form").addEventListener("submit", function (e) { e.preventDefault(); });
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { input.placeholder = STATIC_HINT; return; }
      var visible = true, held = false, resume = null;
      function blocked() { return !visible || held || input.value.length > 0; }
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (!blocked()) go(); else resume = go;
        });
      }
      function wake() { if (!blocked() && resume) { var r = resume; resume = null; r(); } }
      input.addEventListener("focus", function () { held = true; input.placeholder = STATIC_HINT; });
      input.addEventListener("blur", function () { held = false; wake(); });
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; wake(); }).observe(root);
      }
      function show(t) { if (!held && !input.value) input.placeholder = "Try: " + t; }
      async function loop() {
        var i = 0;
        await wait(800);
        for (;;) {
          var cur = SUGGESTIONS[i];
          for (var n = cur.length; n >= 0; n--) { await wait(22); show(cur.slice(0, n)); }
          i = (i + 1) % SUGGESTIONS.length;
          var next = SUGGESTIONS[i];
          await wait(260);
          for (var k = 1; k <= next.length; k++) { await wait(55 + Math.random() * 60 + (next[k - 1] === " " ? 60 : 0)); show(next.slice(0, k)); }
          await wait(1600);
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: The field shows one fixed hint, Search hinges, oils, tools and more, and never cycles.

Running cost: Changes one placeholder attribute on a timer and halts entirely while the field is focused, filled or scrolled away.

Shop Support Reply Typed Word by Word

A ceramics shop support thread on warm stone: a customer asks about speckled mugs and the reply from Maya fills in word by word, each word lifting out of a slight blur, with longer beats after commas and full stops. Between loops three bobbing dots show she is writing. The bubble is sized to the full answer, so the thread never grows mid-sentence.

<!-- A shop's support reply that arrives word by word after a short "typing" pause. -->
<section class="ssr-root">
  <style>
    /* Values to change:
       .ssr-me / .ssr-them backgrounds   bubble colours
       .ssr-av background                avatar colour
       REPLY and START_AT in the script  the answer and how much is shown on load
       the 70 + random * 110 delay       pace between words */
    .ssr-root { padding: 64px 16px; font-family: ui-sans-serif, system-ui, sans-serif; }
    .ssr-root * { box-sizing: border-box; }
    .ssr-root p, .ssr-root h2 { margin: 0; }
    .ssr-root { background: linear-gradient(180deg, #f4f1ec 0%, #ebe6de 100%); }
    .ssr-card { max-width: 30rem; margin: 0 auto; border-radius: 20px; background: #ffffff; border: 1px solid #e2dbd0; box-shadow: 0 24px 50px -28px rgba(60,45,30,0.35); overflow: hidden; }
    .ssr-top { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #eee8df; }
    .ssr-av { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: #9b5a3c; color: #fff; font-size: 13px; font-weight: 600; }
    .ssr-name { font-size: 14px; font-weight: 600; color: #2b2520; }
    .ssr-status { font-size: 12px; color: #5f6b5a; }
    .ssr-thread { display: flex; flex-direction: column; gap: 12px; padding: 20px 18px 24px; }
    .ssr-msg { max-width: 85%; padding: 11px 14px; border-radius: 16px; font-size: 15px; line-height: 1.5; }
    .ssr-me { align-self: flex-end; background: #2f3b33; color: #f3f1ec; border-bottom-right-radius: 5px; }
    .ssr-them { align-self: flex-start; background: #f3ede4; color: #2b2520; border-bottom-left-radius: 5px; }
    .ssr-stack { display: grid; }
    .ssr-stack > * { grid-area: 1 / 1; }
    .ssr-ghost { visibility: hidden; }
    .ssr-w { display: inline; animation: ssr-in 260ms ease-out both; }
    @keyframes ssr-in { from { opacity: 0; filter: blur(2px); } to { opacity: 1; filter: blur(0); } }
    .ssr-dots { display: flex; gap: 5px; align-items: center; height: 1.5em; }
    .ssr-dots i { width: 7px; height: 7px; border-radius: 50%; background: #9b8a78; animation: ssr-bob 1.1s ease-in-out infinite; }
    .ssr-dots i:nth-child(2) { animation-delay: 0.15s; } .ssr-dots i:nth-child(3) { animation-delay: 0.3s; }
    @keyframes ssr-bob { 0%, 60%, 100% { transform: translateY(0); opacity: 0.55; } 30% { transform: translateY(-4px); opacity: 1; } }
    .ssr-time { font-size: 11px; color: #6b6158; }
    @media (prefers-reduced-motion: reduce) { .ssr-w, .ssr-dots i { animation: none; } }
    .ssr-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
  </style>
  <div class="ssr-card">
    <div class="ssr-top">
      <span class="ssr-av" aria-hidden="true">MO</span>
      <div>
        <h2 class="ssr-name">Maya at Kiln &amp; Clay</h2>
        <p class="ssr-status">Usually replies within the hour</p>
      </div>
    </div>
    <div class="ssr-thread">
      <p class="ssr-msg ssr-me"><span class="ssr-sr">You said: </span>Hi, will the speckled mugs be back before the 20th? It's for my sister's birthday.</p>
      <div class="ssr-msg ssr-them">
        <p class="ssr-sr ssr-full"></p>
        <div class="ssr-stack" aria-hidden="true">
          <span class="ssr-ghost"></span>
          <span class="ssr-live"></span>
        </div>
      </div>
      <span class="ssr-time" aria-hidden="true">Seen 2:14 pm</span>
    </div>
  </div>
  <script>
    (function () {
      var REPLY = "Good news: a fresh batch of speckled mugs comes out of the kiln on Thursday. I can set two aside for you now and post them Friday morning, so they arrive in plenty of time.";
      var START_AT = 11; /* words shown on load */
      var WORDS = REPLY.split(" ");
      var root = document.currentScript.parentNode;
      var live = root.querySelector(".ssr-live");
      root.querySelector(".ssr-full").textContent = "Maya replied: " + REPLY;
      root.querySelector(".ssr-ghost").textContent = REPLY;

      function clear() { while (live.firstChild) live.removeChild(live.firstChild); }
      function addWord(i, still) {
        var s = document.createElement("span");
        s.className = "ssr-w";
        if (still) s.style.animation = "none";
        s.textContent = WORDS[i] + " ";
        live.appendChild(s);
      }
      function dots() {
        clear();
        var d = document.createElement("span");
        d.className = "ssr-dots";
        for (var k = 0; k < 3; k++) d.appendChild(document.createElement("i"));
        live.appendChild(d);
      }
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        for (var a = 0; a < WORDS.length; a++) addWord(a, true);
        return;
      }
      for (var b = 0; b < START_AT; b++) addWord(b, true);

      var visible = true, resume = null;
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (visible) go(); else resume = go;
        });
      }
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && resume) { var r = resume; resume = null; r(); }
        }).observe(root);
      }
      async function loop() {
        var n = START_AT;
        for (;;) {
          while (n < WORDS.length) {
            var prev = WORDS[n - 1] || "";
            await wait(/[.,:]$/.test(prev) ? 360 : 70 + Math.random() * 110);
            addWord(n, false); n++;
          }
          await wait(4200);
          n = 0; dots();
          await wait(1500);
          clear();
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: Both messages are complete on arrival and the typing dots never appear.

Running cost: Appends one span per word with a short opacity and blur fade, around thirty in a cycle, and waits while off screen.

Terminal Command Typewriter

A graphite terminal window where a deploy command types itself after the prompt and four lines of output land one by one, ending on a green tick. It is all CSS: a steps() width animation for the command and a separate keyframe per output line, looping every nine seconds. Good for a developer tool page or a changelog header.

<!-- Terminal typewriter: pure CSS. The command types with steps() + ch, output lines appear on their own beats. -->
<section class="ttw-root" data-paused="false">
  <style>
    /* Values to change:
       --ttw-n      characters in the command (also change steps(28) in ttw-type)
       --ttw-cycle  length of one full loop
       .ttw-p / .ttw-ok colours  prompt and success green
       the 28% / 36% / 46% / 56.5% stops in ttw-o1..o4 set when each output line appears */
    .ttw-root { --ttw-n: 28; --ttw-cycle: 9s; }
    .ttw-root { background: #e9ecef; padding: 80px 16px; font-family: ui-sans-serif, system-ui, sans-serif; }
    .ttw-root * { box-sizing: border-box; }
    .ttw-win { max-width: 40rem; margin: 0 auto; border-radius: 14px; background: #1b1e22; box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset, 0 30px 60px -30px rgba(15,20,25,0.55), 0 12px 24px -12px rgba(15,20,25,0.35); overflow: hidden; }
    .ttw-bar { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; background: #24282d; border-bottom: 1px solid #30353b; }
    .ttw-dot { width: 11px; height: 11px; border-radius: 50%; background: #4a5058; }
    .ttw-title { flex: 1; text-align: center; margin-right: 46px; font-size: 12px; color: #a3abb4; }
    .ttw-body { padding: 22px 16px 26px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: clamp(11px, 2.9vw, 15.5px); line-height: 1.75; color: #e4e4df; white-space: nowrap; }
    .ttw-p { color: #93c3a3; }
    .ttw-dim { color: #a3abb4; }
    .ttw-ok { color: #93c3a3; }
    .ttw-cmd { display: inline-block; overflow: hidden; vertical-align: bottom; width: calc(var(--ttw-n) * 1ch); animation: ttw-type var(--ttw-cycle) infinite; animation-delay: -2.2s; }
    .ttw-caret { display: inline-block; width: 0.6ch; height: 1.15em; vertical-align: -0.2em; background: #e4e4df; animation: ttw-blink 1s steps(1) infinite; }
    .ttw-out { opacity: 0; animation: var(--ttw-cycle) infinite; animation-delay: -2.2s; }
    .ttw-o1 { animation-name: ttw-o1; } .ttw-o2 { animation-name: ttw-o2; } .ttw-o3 { animation-name: ttw-o3; } .ttw-o4 { animation-name: ttw-o4; }
    @keyframes ttw-type { 0% { width: 0; animation-timing-function: steps(28, end); } 22%, 94% { width: calc(var(--ttw-n) * 1ch); } 95%, 100% { width: 0; } }
    @keyframes ttw-o1 { 0%, 27% { opacity: 0; } 28%, 93% { opacity: 1; } 95%, 100% { opacity: 0; } }
    @keyframes ttw-o2 { 0%, 35% { opacity: 0; } 36%, 93% { opacity: 1; } 95%, 100% { opacity: 0; } }
    @keyframes ttw-o3 { 0%, 45% { opacity: 0; } 46%, 93% { opacity: 1; } 95%, 100% { opacity: 0; } }
    @keyframes ttw-o4 { 0%, 55% { opacity: 0; } 56.5%, 93% { opacity: 1; } 95%, 100% { opacity: 0; } }
    @keyframes ttw-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .ttw-root[data-paused="true"] .ttw-cmd, .ttw-root[data-paused="true"] .ttw-out, .ttw-root[data-paused="true"] .ttw-caret { animation-play-state: paused; }
    .ttw-copy { max-width: 40rem; margin: 22px auto 0; text-align: center; font-size: 15px; color: #475569; }
    .ttw-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    @media (prefers-reduced-motion: reduce) { .ttw-cmd, .ttw-caret { animation: none; } .ttw-out { animation: none; opacity: 1; } }
  </style>
  <h2 class="ttw-sr">Deploying the Fern and Fig shop</h2>
  <p class="ttw-sr">Command: shop deploy --env production. Output: Building 18 pages and 42 images. Compressing photos for phones. Checking every link on the site. Live at fernandfig.shop.</p>
  <div class="ttw-win" aria-hidden="true">
    <div class="ttw-bar"><span class="ttw-dot"></span><span class="ttw-dot"></span><span class="ttw-dot"></span><span class="ttw-title">fern-and-fig: zsh</span></div>
    <div class="ttw-body">
      <div><span class="ttw-p">~/fern-and-fig $ </span><span class="ttw-cmd">shop deploy --env production</span><span class="ttw-caret"></span></div>
      <div class="ttw-out ttw-o1 ttw-dim">&#8250;  Building 18 pages and 42 images</div>
      <div class="ttw-out ttw-o2 ttw-dim">&#8250;  Compressing photos for phones</div>
      <div class="ttw-out ttw-o3 ttw-dim">&#8250;  Checking every link on the site</div>
      <div class="ttw-out ttw-o4 ttw-ok">&#10003;  Live at fernandfig.shop</div>
    </div>
  </div>
  <p class="ttw-copy">Pure CSS: the command types with steps(), and each line of output fades in on its own beat.</p>
  <script>
    /* Optional: pauses the loop while the window is off screen. */
    (function () {
      var root = document.currentScript.parentNode;
      if (!("IntersectionObserver" in window)) return;
      new IntersectionObserver(function (entries) {
        root.setAttribute("data-paused", entries[0].isIntersecting ? "false" : "true");
      }).observe(root);
    })();
  </script>
</section>

With reduced motion: The full command and all four output lines are shown at once, with a steady caret.

Running cost: Six looping CSS animations on text (one width, five opacity) and no script beyond an optional off-screen pause.

Type and Delete Loop with Natural Rhythm

A plant nursery headline on deep forest green where the second line types, holds, backspaces and types the next ending. The rhythm is uneven on purpose: letters arrive with a little jitter, commas and hyphens earn a longer beat, and now and then it hesitates between words. Deleting starts slow and then runs. Every ending is ghosted in the same grid cell, so the box never changes size.

<!-- Type-and-delete loop with a human rhythm. Edit the phrases in the script and the hidden ghosts in the markup. -->
<section class="tdl-root">
  <style>
    /* Values to change:
       .tdl-root background   the forest gradient
       --tdl-ink / --tdl-accent  headline and typed-phrase colours
       HOLD_MS and BASE_MS in the script set the pause on a finished phrase and the typing speed */
    .tdl-root { --tdl-ink: #f2ecdc; --tdl-accent: #c9dcb8; background: radial-gradient(120% 90% at 20% 0%, #1f3a2c 0%, #142a20 45%, #0e1f17 100%); padding: 96px 16px; font-family: ui-sans-serif, system-ui, sans-serif; overflow: hidden; }
    .tdl-wrap { max-width: 48rem; margin: 0 auto; }
    .tdl-kicker { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: 0.025em; color: #b9cdb0; }
    .tdl-h { margin: 20px 0 0; font-family: "Iowan Old Style", Palatino, Georgia, serif; font-weight: 500; font-size: clamp(2.1rem, 6vw, 4rem); line-height: 1.12; color: var(--tdl-ink); letter-spacing: -0.01em; }
    .tdl-block { display: block; }
    .tdl-stack { display: grid; }
    .tdl-stack > * { grid-area: 1 / 1; }
    .tdl-ghost { visibility: hidden; }
    .tdl-typed { color: var(--tdl-accent); font-style: italic; }
    .tdl-caret { display: inline-block; width: 3px; height: 0.95em; margin-left: 3px; vertical-align: -0.08em; background: var(--tdl-accent); border-radius: 1px; animation: tdl-blink 1.05s steps(1) infinite; }
    .tdl-root[data-typing="true"] .tdl-caret { animation: none; }
    @keyframes tdl-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .tdl-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    .tdl-copy { max-width: 28rem; margin: 24px 0 0; font-size: 16px; line-height: 1.6; color: #cfd8c8; }
    .tdl-btn { display: inline-flex; align-items: center; height: 44px; margin-top: 32px; padding: 0 24px; border-radius: 999px; background: #e9e2cc; color: #142a20; font-size: 14px; font-weight: 600; text-decoration: none; }
    .tdl-btn:focus-visible { outline: 2px solid var(--tdl-accent); outline-offset: 3px; }
    @media (prefers-reduced-motion: reduce) { .tdl-caret { animation: none; } }
  </style>
  <div class="tdl-wrap">
    <p class="tdl-kicker">Greenhouse No. 4, Ashby Road</p>
    <h1 class="tdl-h">
      <span class="tdl-sr">Plants for a north-facing window, for the shelf the cat ignores, for people who forget to water.</span>
      <span aria-hidden="true">
        <span class="tdl-block">Plants for</span>
        <span class="tdl-stack">
          <span class="tdl-ghost tdl-typed">a north-facing window.</span>
          <span class="tdl-ghost tdl-typed">the shelf the cat ignores.</span>
          <span class="tdl-ghost tdl-typed">people who forget to water.</span>
          <span><span class="tdl-typed tdl-out">a north-facing window.</span><span class="tdl-caret"></span></span>
        </span>
      </span>
    </h1>
    <p class="tdl-copy">Tell us about the light in your room and we will pot up something that will still be thriving next spring.</p>
    <a class="tdl-btn" href="#">Find my plant</a>
  </div>
  <script>
    (function () {
      var PHRASES = ["a north-facing window.", "the shelf the cat ignores.", "people who forget to water."];
      var HOLD_MS = 1700; /* pause on a finished phrase */
      var BASE_MS = 48;   /* typical gap between letters */
      var root = document.currentScript.parentNode;
      var out = root.querySelector(".tdl-out");
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      var visible = true, resume = null;
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (visible) go(); else resume = go;
        });
      }
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && resume) { var r = resume; resume = null; r(); }
        }).observe(root);
      }
      function gapAfter(ch) {
        var j = BASE_MS + Math.random() * 70;
        if (ch === "," || ch === ";") return j + 260;
        if (ch === "-") return j + 120;
        if (ch === " ") return j + (Math.random() < 0.18 ? 240 : 40);
        return j;
      }
      async function loop() {
        var index = 0;
        await wait(900);
        for (;;) {
          var current = PHRASES[index];
          root.setAttribute("data-typing", "true");
          for (var n = current.length; n >= 0; n--) {
            out.textContent = current.slice(0, n);
            await wait(n > current.length - 4 ? 70 : 26);
          }
          index = (index + 1) % PHRASES.length;
          var next = PHRASES[index];
          await wait(380);
          for (var k = 1; k <= next.length; k++) {
            out.textContent = next.slice(0, k);
            await wait(gapAfter(next[k - 1]));
          }
          root.setAttribute("data-typing", "false");
          await wait(HOLD_MS);
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: The first ending is shown complete with a still caret, and the script never starts.

Running cost: One text node updated by a timer roughly every 50ms while typing; the loop waits whenever the headline is off screen.

Headline That Retypes One Word

A sourdough bakery headline on warm sand where only the last words change. The old ending is highlighted as if selected with a cursor, cleared, and the new one is typed in its place. Now and then the word goes in with a transposed letter, sits for a moment, and is fixed with backspaces, which makes it feel typed by a person.

<!-- Headline where one word is selected, cleared and retyped, with the occasional typo fixed by backspace. -->
<section class="rwh-root">
  <style>
    /* Values to change:
       --rwh-ink     headline colour
       --rwh-word    colour of the swapped word and caret
       --rwh-select  selection highlight behind the word
       WORDS in the script (keep one rwh-ghost line per word in the markup) */
    .rwh-root { --rwh-ink: #2d241a; --rwh-word: #8a4a1f; --rwh-select: #e3c39c; background: radial-gradient(130% 100% at 80% 0%, #f6eddc 0%, #efe3cc 55%, #e6d6ba 100%); padding: 96px 16px; font-family: ui-sans-serif, system-ui, sans-serif; }
    .rwh-wrap { max-width: 56rem; margin: 0 auto; }
    .rwh-kicker { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; color: #6b4a2a; }
    .rwh-h { margin: 20px 0 0; font-family: "Iowan Old Style", Palatino, Georgia, serif; font-weight: 600; font-size: clamp(2.2rem, 6.4vw, 4.6rem); line-height: 1.06; letter-spacing: -0.02em; color: var(--rwh-ink); }
    .rwh-stack { display: grid; }
    .rwh-stack > * { grid-area: 1 / 1; }
    .rwh-ghost { visibility: hidden; }
    .rwh-word { color: var(--rwh-word); border-radius: 4px; transition: background-color 120ms; }
    .rwh-word[data-sel="true"] { background: var(--rwh-select); }
    .rwh-caret { display: inline-block; width: 3px; height: 0.9em; margin-left: 2px; vertical-align: -0.06em; background: var(--rwh-word); animation: rwh-blink 1.05s steps(1) infinite; }
    @keyframes rwh-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    .rwh-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
    .rwh-copy { max-width: 32rem; margin: 24px 0 0; font-size: 16px; line-height: 1.6; color: #4f3d2b; }
    .rwh-btn { display: inline-flex; align-items: center; height: 44px; margin-top: 32px; padding: 0 24px; border-radius: 8px; background: var(--rwh-ink); color: #f6eddc; font-size: 14px; font-weight: 600; text-decoration: none; }
    .rwh-btn:focus-visible { outline: 2px solid var(--rwh-word); outline-offset: 3px; }
    @media (prefers-reduced-motion: reduce) { .rwh-caret { animation: none; } .rwh-word { transition: none; } }
  </style>
  <div class="rwh-wrap">
    <p class="rwh-kicker">Crumb &amp; Kiln</p>
    <h1 class="rwh-h">
      <span class="rwh-sr">Sourdough, baked before sunrise, before the school run, before you wake up.</span>
      <span class="rwh-stack" aria-hidden="true">
        <span class="rwh-ghost">Sourdough, baked before sunrise.&nbsp;</span>
        <span class="rwh-ghost">Sourdough, baked before the school run.&nbsp;</span>
        <span class="rwh-ghost">Sourdough, baked before you wake up.&nbsp;</span>
        <span>Sourdough, baked before <span class="rwh-word" data-sel="false">you wake up.</span><span class="rwh-caret"></span></span>
      </span>
    </h1>
    <p class="rwh-copy">Long-fermented loaves from a wood-fired oven on Mill Row. Collect warm from seven, or have them left on your step.</p>
    <a class="rwh-btn" href="#">Reserve a loaf</a>
  </div>
  <script>
    (function () {
      var WORDS = [
        { word: "sunrise.", typo: "sunrsie" },
        { word: "the school run.", typo: "" },
        { word: "you wake up.", typo: "" }
      ];
      var root = document.currentScript.parentNode;
      var out = root.querySelector(".rwh-word");
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
      var visible = true, resume = null;
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (visible) go(); else resume = go;
        });
      }
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && resume) { var r = resume; resume = null; r(); }
        }).observe(root);
      }
      async function typeOut(from, to) {
        for (var n = from.length + 1; n <= to.length; n++) { out.textContent = to.slice(0, n); await wait(60 + Math.random() * 70); }
      }
      async function loop() {
        var i = WORDS.length - 1;
        await wait(700);
        for (;;) {
          out.setAttribute("data-sel", "true");
          await wait(520);
          out.setAttribute("data-sel", "false");
          out.textContent = "";
          await wait(180);
          i = (i + 1) % WORDS.length;
          var w = WORDS[i].word, typo = WORDS[i].typo;
          if (typo) {
            await typeOut("", typo);
            await wait(420);
            var keep = 0;
            while (keep < typo.length && typo[keep] === w[keep]) keep++;
            for (var n = typo.length - 1; n >= keep; n--) { out.textContent = typo.slice(0, n); await wait(90); }
            await wait(160);
            await typeOut(w.slice(0, keep), w);
          } else {
            await typeOut("", w);
          }
          await wait(1900);
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: The headline rests on its last ending with a still caret and no highlight.

Running cost: Edits a single text node and one data attribute on timers; nothing runs while the headline is off screen.

Typewritten Letter on Paper

A note from an upholsterer typed onto a cream sheet resting on a slate desk. Each character is struck with slightly different ink, a touch lighter or heavier and a fraction off the baseline, under a soft bleed. A low underscore caret sits where the next key lands, and every line break pauses like a carriage return. The finished letter rests, fades and is typed again.

<!-- Typewritten letter on paper: each character gets its own slightly uneven ink, with a carriage-return pause per line. -->
<section class="twl-root">
  <style>
    /* Values to change:
       .twl-root background   the desk colour
       .twl-paper background  the paper tone and fold line
       .twl-type font-size    letter size
       START_AT and the gaps in gapAfter() in the script set how far in it starts and the typing pace */
    .twl-root { padding: 64px 16px; font-family: ui-sans-serif, system-ui, sans-serif; }
    .twl-root * { box-sizing: border-box; }
    .twl-root { background: radial-gradient(120% 80% at 50% 0%, #46525a 0%, #2f383e 60%, #262d32 100%); }
    .twl-paper { position: relative; max-width: 36rem; margin: 0 auto; padding: 44px clamp(22px, 6vw, 56px) 52px; border-radius: 3px; color: #1f1b17;
      background: linear-gradient(180deg, rgba(0,0,0,0) 48.6%, rgba(90,70,40,0.07) 49.8%, rgba(255,255,255,0.35) 50.4%, rgba(0,0,0,0) 51.5%), linear-gradient(170deg, #fbf6ea 0%, #f4ecdb 100%);
      box-shadow: 0 1px 1px rgba(0,0,0,0.2), 0 24px 50px -20px rgba(0,0,0,0.6), 0 8px 18px -8px rgba(0,0,0,0.35); }
    .twl-head { font-family: Georgia, serif; font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase; color: #5a4632; border-bottom: 1px solid rgba(90,70,50,0.25); padding-bottom: 14px; margin: 0 0 26px; }
    .twl-type { font-family: "American Typewriter", "Courier Prime", "Courier New", Courier, monospace; font-size: clamp(14px, 3.6vw, 17px); line-height: 1.7; white-space: pre-wrap; overflow-wrap: break-word; }
    .twl-stack { display: grid; }
    .twl-stack > * { grid-area: 1 / 1; }
    .twl-ghost { visibility: hidden; }
    .twl-live { transition: opacity 600ms ease; }
    .twl-live[data-fade="true"] { opacity: 0; }
    .twl-ch { position: relative; text-shadow: 0 0 0.6px rgba(31,27,23,0.55); }
    .twl-caret { display: inline-block; width: 0.55em; height: 2px; vertical-align: -0.2em; background: #1f1b17; opacity: 0.8; animation: twl-blink 0.9s steps(1) infinite; }
    @keyframes twl-blink { 0%, 55% { opacity: 0.8; } 56%, 100% { opacity: 0; } }
    @media (prefers-reduced-motion: reduce) { .twl-caret { animation: none; } .twl-live { transition: none; } }
    .twl-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
  </style>
  <article class="twl-paper">
    <h2 class="twl-head">Hale &amp; Daughter Upholstery</h2>
    <p class="twl-sr twl-full"></p>
    <div class="twl-type twl-stack" aria-hidden="true">
      <div class="twl-ghost"></div>
      <div class="twl-live" data-fade="false"><span class="twl-caret"></span></div>
    </div>
  </article>
  <script>
    (function () {
      var LETTER = "Dear Mrs. Okafor,\n\nYour armchair is ready. We kept the original springs and covered it in the moss wool you chose in March.\n\nCollect it any weekday after ten, or we can bring it round on Saturday.\n\nWith thanks,\nRuth Hale";
      var START_AT = Math.floor(LETTER.length * 0.5); /* begins part-way so the page is never blank */
      var root = document.currentScript.parentNode;
      var live = root.querySelector(".twl-live");
      var caret = root.querySelector(".twl-caret");
      root.querySelector(".twl-full").textContent = LETTER.replace(/\n+/g, " ");
      root.querySelector(".twl-ghost").textContent = LETTER;

      function ink(i) {
        var a = Math.sin(i * 12.9898) * 43758.5453, b = Math.sin(i * 78.233) * 12543.1;
        return { o: 0.72 + (a - Math.floor(a)) * 0.28, t: ((b - Math.floor(b) - 0.5) * 0.9).toFixed(2) + "px" };
      }
      function addChar(i) {
        var s = document.createElement("span");
        var k = ink(i);
        s.className = "twl-ch";
        s.style.opacity = k.o;
        s.style.top = k.t;
        s.textContent = LETTER[i];
        live.insertBefore(s, caret);
      }
      function clear() { while (live.firstChild !== caret) live.removeChild(live.firstChild); }
      function gapAfter(ch) {
        if (ch === "\n") return 430;
        if (ch === "." || ch === ",") return 210 + Math.random() * 120;
        if (ch === " ") return 70 + Math.random() * 60;
        return 30 + Math.random() * 40;
      }

      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        for (var j = 0; j < LETTER.length; j++) addChar(j);
        return;
      }
      for (var s0 = 0; s0 < START_AT; s0++) addChar(s0);

      var visible = true, resume = null;
      function wait(ms) {
        return new Promise(function (done) {
          function go() { setTimeout(done, ms); }
          if (visible) go(); else resume = go;
        });
      }
      if ("IntersectionObserver" in window) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && resume) { var r = resume; resume = null; r(); }
        }).observe(root);
      }
      async function loop() {
        var n = START_AT;
        for (;;) {
          while (n < LETTER.length) { await wait(gapAfter(LETTER[n - 1] || "")); addChar(n); n++; }
          await wait(3600);
          live.setAttribute("data-fade", "true");
          await wait(700);
          clear(); n = 0;
          live.setAttribute("data-fade", "false");
          await wait(500);
        }
      }
      loop();
    })();
  </script>
</section>

With reduced motion: The full letter is printed at once with its uneven ink and a still caret.

Running cost: Adds one small span per character on a timer, a few hundred at most, and stops while the letter is off screen.

Performance notes

The CSS typewriters animate width or clip-path in steps, which costs one small layout or paint per character and nothing between steps. The script versions change one text node at a time on a timer, which is equally light; neither needs a frame loop.

Timers keep firing in a background tab at a reduced rate, so each script version also watches visibility and stops when the text is off screen or the tab is hidden, then carries on from where it was.

Questions

How do I make a typewriter effect with only CSS?

Set a monospace font, give the element width: 0 and white-space: nowrap with overflow hidden, then animate width to the number of characters in ch units using steps() with the same number. Add a blinking border-right for the caret.

Can a CSS typewriter use a normal, proportional font?

Not with the ch-width trick, because letters have different widths. Animate a clip-path inset instead, still with steps(); the reveal lands between letters closely enough to read as typing.

How do I type several phrases in a loop?

Use a small script that adds one character at a time, waits, removes them one at a time, then moves to the next phrase. Keep the phrases in an array at the top so they are easy to edit.

Is a typewriter effect accessible?

It is when the full text is available at once to assistive technology and the animated copy is hidden from it. Also stop the loop for reduced motion and show the first phrase complete.

More collections: CSS Text Animations, Text Reveal and Scramble Animations, Number Counter Animations. All text effects.

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap