CSS Text Animations

Updated 2026-10-14. 11 effects.

CSS text animation moves headline letters or words with @keyframes, usually by wrapping each piece in a span and staggering its animation-delay. The catch is accessibility: split letters can be read one by one. Every effect here keeps the full sentence as real text for screen readers, hides the animated pieces from them, and never shifts the layout.

Most headlines need one entrance and then stillness. Use the rising, flipping and line-by-line effects for a hero that plays once on load, and the sheen, highlight and underline effects to draw the eye to a single phrase in an otherwise calm section.

Splitting a word into letters is done once, either in the markup or with a few lines of script that build spans with textContent. The animation itself is pure CSS, so it keeps running smoothly while the page loads the rest of its scripts.

Compare them

EffectBuilt withSize (gzipped)Running cost
Letters Rising with Blur to SharpCSS only1.7 KBAnimates transform, opacity and a blur filter on about 25 letter spans; the blur repaints briefly while letters arrive and leave.
Breathing Letter Spacing Display LineCSS only1.9 KBTransforms and opacity on about ten letters plus one small dot; runs on the compositor with no layout work.
Hand-Drawn Underline Under One WordCSS only1.6 KBAnimates stroke-dashoffset on two short SVG paths; a tiny repaint of the underline only.
Letters Flipping into Place in 3DCSS only1.8 KB3D transforms and opacity on about 25 letter spans, composited by the browser with no repaint.
Marker Highlight Wiping Behind a PhraseCSS only1.4 KBAnimates background-size on one inline span, a small repaint of that phrase; no JavaScript needed for the effect.
Outline Text Filling from the Baseline UpCSS only1.6 KBAnimates clip-path on one text layer; the browser repaints only the headline while the level moves.
Paragraph Lines Sliding Up from a MaskCSS only1.6 KBFour transformed blocks clipped by overflow: hidden; runs on the compositor with no repaint.
Rolling Word Rotator in a Fixed SlotCSS only1.6 KBOne transformed element moves a few times per cycle; no JavaScript beyond the pause observer.
Text Shadow Depth Following a Moving LightCSS only1.5 KBAnimates text-shadow, which repaints the headline every frame, plus one transformed light layer; keep it to one heading.
Variable Font Weight Wave Across a HeadlineCSS only1.8 KBChanging font weight repaints the glyphs each frame; fine for one headline, too heavy for body text.
Wavy Baseline Ripple Through a WordCSS only2 KBNine letters and one SVG strip move with transforms only; the loop is cheap and runs on the compositor.

Letters Rising with Blur to Sharp

A headline animation for a warm, editorial hero: every letter lifts a little from below its baseline while it comes out of a soft blur, so the line seems to come into focus rather than slide in. Letters follow each other a few milliseconds apart, the sentence holds for several seconds, then drifts away and returns. Words stay whole, so the line wraps naturally on a phone.

<section class="rb-root" data-paused="false">
  <div class="rb-inner">
    <p class="rb-eyebrow">Halden Street Bakery</p>
    <h2 class="rb-h" data-rb-split>Bread worth waking up for.</h2>
    <p class="rb-copy">Long-fermented sourdough and seeded rye, out of the oven at half six and usually gone by noon on Saturdays.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.rb-root {
  --rb-bg: #f4eee3;
  --rb-ink: #1f1a14;
  --rb-muted: #65584a;
  --rb-accent: #b0652c;
  --rb-speed: 7.5s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--rb-bg);
  color: var(--rb-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.rb-root *, .rb-root *::before, .rb-root *::after { box-sizing: border-box; }
.rb-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.rb-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--rb-muted); }
.rb-h { margin: 0; font-size: clamp(46px, 8.6vw, 112px); font-weight: 800; line-height: .98; letter-spacing: -.035em; }
.rb-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--rb-muted); }
.rb-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.rb-w { display: inline-block; white-space: nowrap; }
.rb-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.rb-root[data-paused="true"] *, .rb-root[data-paused="true"] *::before, .rb-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .rb-root *, .rb-root *::before, .rb-root *::after { animation: none !important; }
}

.rb-h { max-width: 11em; }
.rb-l {
  animation: rb-rise var(--rb-speed) cubic-bezier(.2, .7, .2, 1) infinite both;
  animation-delay: calc(var(--i) * 28ms); /* gap between letters */
}
.rb-eyebrow::before { content: ""; display: inline-block; width: 28px; height: 1px; margin-right: 12px; vertical-align: middle; background: var(--rb-accent); }
@keyframes rb-rise {
  0% { opacity: 0; transform: translateY(.5em); filter: blur(14px); }
  12% { opacity: 1; transform: translateY(0); filter: blur(0); }
  86% { opacity: 1; transform: translateY(0); filter: blur(0); animation-timing-function: cubic-bezier(.6, 0, .8, .3); }
  97%, 100% { opacity: 0; transform: translateY(-.15em); filter: blur(10px); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".rb-root");
  roots.forEach(function (root) {
  // Split each marked element into word and letter spans. Screen readers get one plain copy.
  root.querySelectorAll("[data-rb-split]").forEach(function (el) {
    var text = el.textContent.trim();
    var hidden = el.getAttribute("aria-hidden") === "true";
    el.textContent = "";
    if (!hidden) {
      var sr = document.createElement("span");
      sr.className = "rb-sr";
      sr.textContent = text;
      el.appendChild(sr);
    }
    var vis = document.createElement("span");
    vis.setAttribute("aria-hidden", "true");
    var i = 0;
    text.split(" ").forEach(function (word, w) {
      if (w > 0) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span");
      ws.className = "rb-w";
      Array.from(word).forEach(function (ch) {
        var l = document.createElement("span");
        l.className = "rb-l";
        l.style.setProperty("--i", i++);
        l.textContent = ch;
        ws.appendChild(l);
      });
      vis.appendChild(ws);
    });
    el.style.setProperty("--c", (i - 1) / 2);
    el.appendChild(vis);
  });

    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The headline sits still and fully sharp, with every letter in its final place.

Running cost: Animates transform, opacity and a blur filter on about 25 letter spans; the blur repaints briefly while letters arrive and leave.

Full page for Letters Rising with Blur to Sharp

Breathing Letter Spacing Display Line

A wide uppercase line that slowly opens and closes around its centre, like a breath, with a small lamp above it brightening in time. The letters move apart with transforms rather than real letter spacing, so the line never reflows and the copy underneath stays put. Built for a late-night, low-light brand.

<section class="ls-root" data-paused="false">
  <div class="ls-inner">
    <p class="ls-eyebrow">Nightjar Reading Room</p>
    <span class="ls-lamp" aria-hidden="true"></span>
    <h2 class="ls-h" data-ls-split>Quiet hours</h2>
    <p class="ls-copy">A members' library on Fenner Street, open until midnight from Thursday to Sunday. Phones rest in a basket by the door.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.ls-root {
  --ls-bg: #0c1322;
  --ls-ink: #e9edf5;
  --ls-muted: #a3aec2;
  --ls-accent: #f4d9a6;
  --ls-speed: 7s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--ls-bg);
  color: var(--ls-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.ls-root *, .ls-root *::before, .ls-root *::after { box-sizing: border-box; }
.ls-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; text-align: center; }
.ls-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--ls-muted); }
.ls-h { margin: 0; font-size: clamp(34px, 9vw, 128px); font-weight: 700; line-height: 1; letter-spacing: .05em; }
.ls-copy { margin: 28px auto 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--ls-muted); }
.ls-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ls-w { display: inline-block; white-space: nowrap; }
.ls-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.ls-root[data-paused="true"] *, .ls-root[data-paused="true"] *::before, .ls-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .ls-root *, .ls-root *::before, .ls-root *::after { animation: none !important; }
}

.ls-root { background: radial-gradient(ellipse 60% 55% at 50% 48%, #1d2b49 0%, var(--ls-bg) 70%); }
.ls-h { text-transform: uppercase; white-space: nowrap; text-shadow: 0 0 40px rgba(244, 217, 166, .18); }
.ls-l {
  /* --c is the middle letter, so the line opens and closes around its centre. */
  animation: ls-breathe var(--ls-speed) cubic-bezier(.45, 0, .55, 1) infinite;
}
.ls-lamp { display: block; width: 6px; height: 6px; margin: 0 auto 26px; border-radius: 50%; background: var(--ls-accent); box-shadow: 0 0 18px 4px rgba(244, 217, 166, .45); animation: ls-glow var(--ls-speed) ease-in-out infinite; }
@keyframes ls-breathe {
  0%, 100% { transform: translateX(calc((var(--i) - var(--c)) * -.02em)); opacity: .8; }
  50% { transform: translateX(calc((var(--i) - var(--c)) * .09em)); opacity: 1; }
}
@keyframes ls-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".ls-root");
  roots.forEach(function (root) {
  // Split each marked element into word and letter spans. Screen readers get one plain copy.
  root.querySelectorAll("[data-ls-split]").forEach(function (el) {
    var text = el.textContent.trim();
    var hidden = el.getAttribute("aria-hidden") === "true";
    el.textContent = "";
    if (!hidden) {
      var sr = document.createElement("span");
      sr.className = "ls-sr";
      sr.textContent = text;
      el.appendChild(sr);
    }
    var vis = document.createElement("span");
    vis.setAttribute("aria-hidden", "true");
    var i = 0;
    text.split(" ").forEach(function (word, w) {
      if (w > 0) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span");
      ws.className = "ls-w";
      Array.from(word).forEach(function (ch) {
        var l = document.createElement("span");
        l.className = "ls-l";
        l.style.setProperty("--i", i++);
        l.textContent = ch;
        ws.appendChild(l);
      });
      vis.appendChild(ws);
    });
    el.style.setProperty("--c", (i - 1) / 2);
    el.appendChild(vis);
  });

    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The line rests at its normal spacing and the lamp glows steadily.

Running cost: Transforms and opacity on about ten letters plus one small dot; runs on the compositor with no layout work.

Hand-Drawn Underline Under One Word

A pen stroke draws itself under a single word in the headline, followed by a lighter second pass a moment later, the way someone underlines in a notebook. It holds, then lifts off in the direction it was drawn. The stroke is an inline SVG sized to the word, so it follows the word wherever the line wraps.

<section class="ud-root" data-paused="false">
  <div class="ud-inner">
    <p class="ud-eyebrow">Pell &amp; Daughter Tailors</p>
    <h2 class="ud-h">
      Alterations for the way you
      <span class="ud-mark">actually<svg class="ud-svg" viewBox="0 0 300 26" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path pathLength="1" d="M6 15 C 70 6, 140 20, 210 11 S 282 8, 295 13"/><path pathLength="1" d="M28 22 C 100 15, 196 20, 280 14"/></svg></span>
      move.
    </h2>
    <p class="ud-copy">Hems, sleeves and waistbands adjusted in our Kirkgate shop, usually ready within the week. Bring the shoes you will wear with them.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.ud-root {
  --ud-bg: #f5eee2;
  --ud-ink: #231c17;
  --ud-muted: #5f5246;
  --ud-accent: #c0562c;
  --ud-speed: 6.5s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--ud-bg);
  color: var(--ud-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.ud-root *, .ud-root *::before, .ud-root *::after { box-sizing: border-box; }
.ud-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.ud-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--ud-muted); }
.ud-h { margin: 0; font-size: clamp(40px, 7vw, 88px); font-weight: 800; line-height: 1.06; letter-spacing: -.03em; }
.ud-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--ud-muted); }
.ud-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ud-w { display: inline-block; white-space: nowrap; }
.ud-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.ud-root[data-paused="true"] *, .ud-root[data-paused="true"] *::before, .ud-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .ud-root *, .ud-root *::before, .ud-root *::after { animation: none !important; }
}

.ud-h { max-width: 13em; }
.ud-mark { position: relative; display: inline-block; white-space: nowrap; }
.ud-svg { position: absolute; left: -.05em; bottom: -.2em; width: calc(100% + .1em); height: .34em; overflow: visible; pointer-events: none; }
.ud-svg path {
  fill: none;
  stroke: var(--ud-accent);
  stroke-width: 5;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
  animation: ud-draw var(--ud-speed) cubic-bezier(.6, 0, .25, 1) infinite;
}
.ud-svg path + path { stroke-width: 3.5; animation-name: ud-draw-2; }
@keyframes ud-draw {
  0%, 6% { stroke-dashoffset: 1; opacity: 0; }
  7% { opacity: 1; }
  26%, 84% { stroke-dashoffset: 0; }
  95% { opacity: 1; }
  96%, 100% { stroke-dashoffset: -1; opacity: 0; }
}
@keyframes ud-draw-2 {
  0%, 22% { stroke-dashoffset: 1; opacity: 0; }
  23% { opacity: 1; }
  36%, 84% { stroke-dashoffset: 0; }
  95% { opacity: 1; }
  96%, 100% { stroke-dashoffset: -1; opacity: 0; }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".ud-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: Both strokes are already drawn under the word and stay there.

Running cost: Animates stroke-dashoffset on two short SVG paths; a tiny repaint of the underline only.

Letters Flipping into Place in 3D

Each letter swings up from below on a horizontal hinge, overshoots a touch and settles, one after another along the line, like cards turned face up. The last word picks up an amber accent for the time. After a long hold the letters tip away from the top and the line plays again.

<section class="fl-root" data-paused="false">
  <div class="fl-inner">
    <p class="fl-eyebrow">Fernwick Ferry Company</p>
    <h2 class="fl-h" data-fl-split>Next crossing leaves at 7:40.</h2>
    <p class="fl-copy">Foot passengers and bikes to Holm Island, six sailings a day. Tickets from the hut on the quay or from the crew on board.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.fl-root {
  --fl-bg: #1b2430;
  --fl-ink: #f3efe6;
  --fl-muted: #aab5c3;
  --fl-accent: #f2b84b;
  --fl-speed: 8s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--fl-bg);
  color: var(--fl-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.fl-root *, .fl-root *::before, .fl-root *::after { box-sizing: border-box; }
.fl-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.fl-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--fl-muted); }
.fl-h { margin: 0; font-size: clamp(42px, 7.8vw, 100px); font-weight: 800; line-height: 1.02; letter-spacing: -.035em; }
.fl-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--fl-muted); }
.fl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.fl-w { display: inline-block; white-space: nowrap; }
.fl-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.fl-root[data-paused="true"] *, .fl-root[data-paused="true"] *::before, .fl-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .fl-root *, .fl-root *::before, .fl-root *::after { animation: none !important; }
}

.fl-root { background: linear-gradient(180deg, #222d3b 0%, var(--fl-bg) 60%, #161d27 100%); }
.fl-h { max-width: 11em; }
.fl-h .fl-w { perspective: 700px; }
.fl-h .fl-w:last-child { color: var(--fl-accent); }
.fl-l {
  transform-origin: 50% 85%;
  backface-visibility: hidden;
  animation: fl-flip var(--fl-speed) infinite both;
  animation-delay: calc(var(--i) * 34ms);
}
@keyframes fl-flip {
  0% { opacity: 0; transform: rotateX(-100deg); animation-timing-function: cubic-bezier(.25, 1.45, .45, 1); }
  13% { opacity: 1; transform: rotateX(0); }
  86% { opacity: 1; transform: rotateX(0); animation-timing-function: cubic-bezier(.55, 0, .75, .4); }
  96%, 100% { opacity: 0; transform: rotateX(90deg); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".fl-root");
  roots.forEach(function (root) {
  // Split each marked element into word and letter spans. Screen readers get one plain copy.
  root.querySelectorAll("[data-fl-split]").forEach(function (el) {
    var text = el.textContent.trim();
    var hidden = el.getAttribute("aria-hidden") === "true";
    el.textContent = "";
    if (!hidden) {
      var sr = document.createElement("span");
      sr.className = "fl-sr";
      sr.textContent = text;
      el.appendChild(sr);
    }
    var vis = document.createElement("span");
    vis.setAttribute("aria-hidden", "true");
    var i = 0;
    text.split(" ").forEach(function (word, w) {
      if (w > 0) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span");
      ws.className = "fl-w";
      Array.from(word).forEach(function (ch) {
        var l = document.createElement("span");
        l.className = "fl-l";
        l.style.setProperty("--i", i++);
        l.textContent = ch;
        ws.appendChild(l);
      });
      vis.appendChild(ws);
    });
    el.style.setProperty("--c", (i - 1) / 2);
    el.appendChild(vis);
  });

    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: Every letter stands upright and still, with the time shown in amber.

Running cost: 3D transforms and opacity on about 25 letter spans, composited by the browser with no repaint.

Marker Highlight Wiping Behind a Phrase

A yellow marker sweeps behind the second half of a headline, low on the letters like a real highlighter, and runs line by line when the phrase wraps on a phone. It rests for a while, then clears toward the right. Clean slate type on white keeps it practical for a local trade or a help page.

<section class="mh-root" data-paused="false">
  <div class="mh-inner">
    <p class="mh-eyebrow">Corner Locksmiths of Ashby</p>
    <h2 class="mh-h">
      Same-day key cutting, and <span class="mh-mark">a real person answers the phone.</span>
    </h2>
    <p class="mh-copy">Walk-in key cutting, lock changes and lockouts across the north side. Open eight until six, and until two on Saturdays.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.mh-root {
  --mh-bg: #ffffff;
  --mh-ink: #18212d;
  --mh-muted: #4a5565;
  --mh-accent: #ffd95a;
  --mh-speed: 7s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--mh-bg);
  color: var(--mh-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.mh-root *, .mh-root *::before, .mh-root *::after { box-sizing: border-box; }
.mh-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.mh-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--mh-muted); }
.mh-h { margin: 0; font-size: clamp(36px, 6.2vw, 80px); font-weight: 700; line-height: 1.12; letter-spacing: -.03em; }
.mh-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--mh-muted); }
.mh-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.mh-w { display: inline-block; white-space: nowrap; }
.mh-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.mh-root[data-paused="true"] *, .mh-root[data-paused="true"] *::before, .mh-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .mh-root *, .mh-root *::before, .mh-root *::after { animation: none !important; }
}

.mh-h { max-width: 15em; }
.mh-mark {
  padding: 0 .06em;
  margin: 0 -.06em;
  background-image: linear-gradient(100deg, rgba(255, 217, 90, .75), var(--mh-accent) 6%, var(--mh-accent) 94%, rgba(255, 217, 90, .75));
  background-repeat: no-repeat;
  background-size: 100% 62%;
  background-position: 0 85%;
  animation: mh-wipe var(--mh-speed) cubic-bezier(.65, 0, .25, 1) infinite;
}
/* The default box-decoration-break (slice) makes the wipe run line by line when the phrase wraps. */
@keyframes mh-wipe {
  0%, 6% { background-size: 0% 62%; background-position: 0 85%; }
  34% { background-size: 100% 62%; background-position: 0 85%; }
  84% { background-size: 100% 62%; background-position: 100% 85%; }
  97%, 100% { background-size: 0% 62%; background-position: 100% 85%; }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".mh-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The phrase sits fully highlighted, with no sweep.

Running cost: Animates background-size on one inline span, a small repaint of that phrase; no JavaScript needed for the effect.

Outline Text Filling from the Baseline Up

The headline starts as a thin outline and fills from the bottom up, as if poured, with a warm roast-brown gradient that deepens toward the top. After a pause the level drops away and the outline is left again. Set on a soft dawn background, it fits a roaster, a bakery or any early-morning trade.

<section class="sf-root" data-paused="false">
  <div class="sf-inner">
    <p class="sf-eyebrow">Morrow Hill Coffee Roasters</p>
    <h2 class="sf-h">
      <span class="sf-sr">Roasted before first light.</span>
      <span class="sf-stack" aria-hidden="true">
        <span class="sf-outline">Roasted before first light.</span>
        <span class="sf-fill">Roasted before first light.</span>
      </span>
    </h2>
    <p class="sf-copy">Small batches on a twelve-kilo drum every weekday morning, bagged by ten and on the counter of the shop downstairs by noon.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.sf-root {
  --sf-bg: #f8dcc6;
  --sf-ink: #3a1d14;
  --sf-muted: #62392a;
  --sf-accent: #9a4322;
  --sf-speed: 7s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: linear-gradient(170deg, #fdf1e6 0%, #f8dcc6 55%, #f0c2a4 100%);
  color: var(--sf-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.sf-root *, .sf-root *::before, .sf-root *::after { box-sizing: border-box; }
.sf-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.sf-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--sf-muted); }
.sf-h { margin: 0; font-size: clamp(44px, 8.4vw, 110px); font-weight: 800; line-height: .98; letter-spacing: -.035em; }
.sf-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--sf-muted); }
.sf-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sf-w { display: inline-block; white-space: nowrap; }
.sf-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.sf-root[data-paused="true"] *, .sf-root[data-paused="true"] *::before, .sf-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .sf-root *, .sf-root *::before, .sf-root *::after { animation: none !important; }
}

.sf-h { max-width: 10em; font-family: "Arial Black", "Helvetica Neue", Arial, sans-serif; font-weight: 900; letter-spacing: -.045em; }
.sf-stack { display: grid; }
.sf-outline, .sf-fill { grid-area: 1 / 1; }
.sf-outline { color: transparent; -webkit-text-stroke: .02em var(--sf-ink); }
.sf-fill {
  background: linear-gradient(0deg, var(--sf-accent) 0%, var(--sf-ink) 70%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  clip-path: inset(0 0 0 0);
  animation: sf-flood var(--sf-speed) cubic-bezier(.6, 0, .25, 1) infinite;
}
.sf-root::before { content: ""; position: absolute; right: -8%; top: -18%; width: 52vmax; height: 52vmax; max-width: 640px; max-height: 640px; border-radius: 50%; background: radial-gradient(closest-side, rgba(255, 246, 232, .9), rgba(255, 246, 232, 0)); pointer-events: none; }
@keyframes sf-flood {
  0%, 8% { clip-path: inset(100% 0 0 0); }
  44%, 84% { clip-path: inset(0 0 0 0); }
  97%, 100% { clip-path: inset(100% 0 0 0); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".sf-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The headline is shown fully filled in its warm brown gradient.

Running cost: Animates clip-path on one text layer; the browser repaints only the headline while the level moves.

Paragraph Lines Sliding Up from a Mask

A short serif statement set over four lines, each rising out of its own hidden slot a moment after the one above, the way a printed page might be revealed line by line. The last line turns italic in a soft bronze. Each line keeps its full height while hidden, so the paragraph never changes size.

<section class="ml-root" data-paused="false">
  <div class="ml-inner">
    <p class="ml-eyebrow">Arden Vale Architects</p>
    <h2 class="ml-h">
      <span class="ml-sr">We design small buildings for steep sites, tight budgets and clients who mean to stay for decades.</span>
      <span aria-hidden="true">
        <span class="ml-line"><span class="ml-in" style="--i:0">We design small buildings</span></span>
        <span class="ml-line"><span class="ml-in" style="--i:1">for steep sites, tight budgets</span></span>
        <span class="ml-line"><span class="ml-in" style="--i:2">and clients who mean</span></span>
        <span class="ml-line"><span class="ml-in" style="--i:3">to stay for decades.</span></span>
      </span>
    </h2>
    <p class="ml-copy">A four-person practice in the Calder valley. Houses, studios, a boathouse and one very good village hall.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.ml-root {
  --ml-bg: #ebe6dd;
  --ml-ink: #24211d;
  --ml-muted: #5b5248;
  --ml-accent: #7a5a3a;
  --ml-speed: 9s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--ml-bg);
  color: var(--ml-ink);
  font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
  -webkit-font-smoothing: antialiased;
}
.ml-root *, .ml-root *::before, .ml-root *::after { box-sizing: border-box; }
.ml-inner { position: relative; z-index: 1; width: 100%; max-width: 980px; }
.ml-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--ml-muted); }
.ml-h { margin: 0; font-size: clamp(26px, 4.8vw, 62px); font-weight: 400; line-height: 1.14; letter-spacing: -.015em; }
.ml-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--ml-muted); }
.ml-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ml-w { display: inline-block; white-space: nowrap; }
.ml-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.ml-root[data-paused="true"] *, .ml-root[data-paused="true"] *::before, .ml-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .ml-root *, .ml-root *::before, .ml-root *::after { animation: none !important; }
}

.ml-line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.ml-line:last-child { font-style: italic; color: var(--ml-accent); }
.ml-in {
  display: block;
  animation: ml-up var(--ml-speed) cubic-bezier(.2, .8, .2, 1) infinite both;
  animation-delay: calc(var(--i) * 140ms); /* gap between lines */
}
.ml-copy { border-top: 1px solid rgba(36, 33, 29, .18); padding-top: 22px; }
@keyframes ml-up {
  0% { transform: translateY(110%); }
  12% { transform: translateY(0); }
  86% { transform: translateY(0); animation-timing-function: cubic-bezier(.6, 0, .8, .4); }
  97%, 100% { transform: translateY(-110%); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".ml-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: All four lines are shown in place, with the last line in bronze italic.

Running cost: Four transformed blocks clipped by overflow: hidden; runs on the compositor with no repaint.

Rolling Word Rotator in a Fixed Slot

A two-line headline where the second line is a slot that rolls upward through four phrases, pausing on each one. The slot is sized to the longest phrase before anything moves, so the rule underneath and the copy below never shift. Good for a service business that does several things and wants to name them without a list.

<section class="wr-root" data-paused="false">
  <div class="wr-inner">
    <p class="wr-eyebrow">Spokewell Cycle Repair</p>
    <h2 class="wr-h">
      <span class="wr-sr">We fix flat tyres, loose chains, worn brakes and wobbly wheels.</span>
      <span aria-hidden="true">
        <span class="wr-lead">We fix</span>
        <span class="wr-slot">
          <span class="wr-reserve">flat tyres</span><span class="wr-reserve">loose chains</span><span class="wr-reserve">worn brakes</span><span class="wr-reserve">wobbly wheels</span>
          <span class="wr-reel"><span>flat tyres</span><span>loose chains</span><span>worn brakes</span><span>wobbly wheels</span><span>flat tyres</span></span>
        </span>
        <span class="wr-rule"></span>
      </span>
    </h2>
    <p class="wr-copy">Leave your bike at the Mill Lane workshop before nine and ride it home after five, with the parts list taped to the bars.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.wr-root {
  --wr-bg: #13241c;
  --wr-ink: #eef2e8;
  --wr-muted: #a9b9a5;
  --wr-accent: #bfe08f;
  --wr-speed: 10s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--wr-bg);
  color: var(--wr-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.wr-root *, .wr-root *::before, .wr-root *::after { box-sizing: border-box; }
.wr-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.wr-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--wr-muted); }
.wr-h { margin: 0; font-size: clamp(40px, 7.6vw, 96px); font-weight: 800; line-height: 1.08; letter-spacing: -.035em; }
.wr-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--wr-muted); }
.wr-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.wr-w { display: inline-block; white-space: nowrap; }
.wr-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.wr-root[data-paused="true"] *, .wr-root[data-paused="true"] *::before, .wr-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .wr-root *, .wr-root *::before, .wr-root *::after { animation: none !important; }
}

.wr-root { background: radial-gradient(ellipse at 80% 0%, #1f3a2c 0%, var(--wr-bg) 60%); }
.wr-lead { display: block; }
.wr-slot { position: relative; display: inline-grid; height: 1.08em; overflow: hidden; vertical-align: top; color: var(--wr-accent); }
.wr-reserve { grid-area: 1 / 1; visibility: hidden; white-space: nowrap; }
.wr-reel { position: absolute; left: 0; top: 0; animation: wr-roll var(--wr-speed) infinite; }
.wr-reel span { display: block; height: 1.08em; white-space: nowrap; }
.wr-rule { display: block; width: 100%; height: 3px; margin-top: 10px; border-radius: 2px; background: var(--wr-accent); opacity: .35; }
/* Five stops: four words plus the first again, so the loop never jumps. */
@keyframes wr-roll {
  0%, 20% { transform: translateY(0); animation-timing-function: cubic-bezier(.7, 0, .2, 1); }
  25%, 45% { transform: translateY(-1.08em); animation-timing-function: cubic-bezier(.7, 0, .2, 1); }
  50%, 70% { transform: translateY(-2.16em); animation-timing-function: cubic-bezier(.7, 0, .2, 1); }
  75%, 95% { transform: translateY(-3.24em); animation-timing-function: cubic-bezier(.7, 0, .2, 1); }
  100% { transform: translateY(-4.32em); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".wr-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The slot stays on the first phrase and the reel does not move.

Running cost: One transformed element moves a few times per cycle; no JavaScript beyond the pause observer.

Text Shadow Depth Following a Moving Light

Terracotta lettering with a stacked, extruded shadow that leans away from a warm patch of sunlight drifting slowly across a plaster wall. As the light moves from left to right, the depth swings from one side of the letters to the other, like a sign on a sunny street over an afternoon.

<section class="ts-root" data-paused="false">
  <div class="ts-light" aria-hidden="true"></div>
  <div class="ts-inner">
    <p class="ts-eyebrow">Casa Ombra Ceramics</p>
    <h2 class="ts-h">Glazed in the yard, fired at dusk.</h2>
    <p class="ts-copy">Stoneware plates, jugs and planters from a two-kiln workshop outside Ronda. The studio is open on Saturday mornings.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.ts-root {
  --ts-bg: #e8dccb;
  --ts-ink: #8c3f22;
  --ts-muted: #5c4536;
  --ts-accent: #fff6e4;
  --ts-speed: 11s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--ts-bg);
  color: var(--ts-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.ts-root *, .ts-root *::before, .ts-root *::after { box-sizing: border-box; }
.ts-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.ts-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--ts-muted); }
.ts-h { margin: 0; font-size: clamp(42px, 7.6vw, 98px); font-weight: 800; line-height: 1.06; letter-spacing: -.012em; }
.ts-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--ts-muted); }
.ts-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ts-w { display: inline-block; white-space: nowrap; }
.ts-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.ts-root[data-paused="true"] *, .ts-root[data-paused="true"] *::before, .ts-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .ts-root *, .ts-root *::before, .ts-root *::after { animation: none !important; }
}

.ts-root { --ts-depth: #6b2f17; }
.ts-light {
  position: absolute;
  left: 0;
  top: -40%;
  width: 70%;
  height: 160%;
  background: radial-gradient(closest-side, rgba(255, 246, 228, .95), rgba(255, 246, 228, 0));
  pointer-events: none;
  transform: translateX(20%);
  animation: ts-sun var(--ts-speed) ease-in-out infinite alternate;
}
.ts-h {
  max-width: 11em;
  text-shadow: 0.000em 0.010em 0 var(--ts-depth), 0.000em 0.020em 0 var(--ts-depth), 0.000em 0.030em 0 var(--ts-depth), 0.000em 0.040em 0 var(--ts-depth), 0.000em 0.050em 0 var(--ts-depth), 0.000em .12em .18em rgba(90, 44, 20, .22);
  animation: ts-cast var(--ts-speed) ease-in-out infinite alternate;
}
/* The light travels left to right; the extruded shadow swings the opposite way. */
@keyframes ts-sun { from { transform: translateX(-30%); } to { transform: translateX(70%); } }
@keyframes ts-cast {
  from { text-shadow: 0.010em 0.010em 0 var(--ts-depth), 0.020em 0.020em 0 var(--ts-depth), 0.030em 0.030em 0 var(--ts-depth), 0.040em 0.040em 0 var(--ts-depth), 0.050em 0.050em 0 var(--ts-depth), 0.100em .12em .18em rgba(90, 44, 20, .22); }
  to { text-shadow: -0.010em 0.010em 0 var(--ts-depth), -0.020em 0.020em 0 var(--ts-depth), -0.030em 0.030em 0 var(--ts-depth), -0.040em 0.040em 0 var(--ts-depth), -0.050em 0.050em 0 var(--ts-depth), -0.100em .12em .18em rgba(90, 44, 20, .22); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".ts-root");
  roots.forEach(function (root) {
    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The light rests above the middle of the line and the shadow falls straight down.

Running cost: Animates text-shadow, which repaints the headline every frame, plus one transformed light layer; keep it to one heading.

Variable Font Weight Wave Across a Headline

A swell of weight runs along the headline: letters thicken from hairline to black and warm to a rust red as the wave passes, then thin out again. It uses the weight axis of a variable font, falling back to the system font's own weights. Each letter keeps a fixed cell, so the line does not wobble.

<section class="vw-root" data-paused="false">
  <div class="vw-inner">
    <p class="vw-eyebrow">Bellows Records &amp; Listening Bar</p>
    <h2 class="vw-h" data-vw-split>Turn it up on Friday nights.</h2>
    <p class="vw-copy">New arrivals on the front wall, a different guest selector every week, and the good speakers stay on until one.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.vw-root {
  --vw-bg: #efe3d0;
  --vw-ink: #2a1b12;
  --vw-muted: #5e4433;
  --vw-accent: #a8401f;
  --vw-speed: 2.6s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--vw-bg);
  color: var(--vw-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.vw-root *, .vw-root *::before, .vw-root *::after { box-sizing: border-box; }
.vw-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.vw-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--vw-muted); }
.vw-h { margin: 0; font-size: clamp(44px, 8vw, 104px); font-weight: 300; line-height: 1; letter-spacing: -.025em; }
.vw-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--vw-muted); }
.vw-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.vw-w { display: inline-block; white-space: nowrap; }
.vw-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.vw-root[data-paused="true"] *, .vw-root[data-paused="true"] *::before, .vw-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .vw-root *, .vw-root *::before, .vw-root *::after { animation: none !important; }
}

.vw-h { max-width: 11em; }
/* Each letter reserves the width of its boldest version, so the line never shifts as weights change. */
.vw-l { display: inline-grid; justify-items: center; }
.vw-l::after { content: attr(data-ch); grid-area: 1 / 1; font-weight: 900; visibility: hidden; }
.vw-g {
  grid-area: 1 / 1;
  font-weight: 560;
  animation: vw-pulse var(--vw-speed) ease-in-out infinite;
  animation-delay: calc(var(--i) * 55ms - 3s); /* wave spacing */
}
/* font-variation-settings drives variable fonts; font-weight covers fonts without a wght axis. */
@keyframes vw-pulse {
  0%, 100% { font-weight: 260; font-variation-settings: "wght" 260; color: var(--vw-ink); }
  50% { font-weight: 900; font-variation-settings: "wght" 900; color: var(--vw-accent); }
}
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".vw-root");
  roots.forEach(function (root) {
  // Split each marked element into word and letter spans. Screen readers get one plain copy.
  root.querySelectorAll("[data-vw-split]").forEach(function (el) {
    var text = el.textContent.trim();
    var hidden = el.getAttribute("aria-hidden") === "true";
    el.textContent = "";
    if (!hidden) {
      var sr = document.createElement("span");
      sr.className = "vw-sr";
      sr.textContent = text;
      el.appendChild(sr);
    }
    var vis = document.createElement("span");
    vis.setAttribute("aria-hidden", "true");
    var i = 0;
    text.split(" ").forEach(function (word, w) {
      if (w > 0) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span");
      ws.className = "vw-w";
      Array.from(word).forEach(function (ch) {
        var l = document.createElement("span");
        l.className = "vw-l";
        l.style.setProperty("--i", i++);
        l.setAttribute("data-ch", ch);
        var g = document.createElement("span");
        g.className = "vw-g";
        g.textContent = ch;
        l.appendChild(g);
        ws.appendChild(l);
      });
      vis.appendChild(ws);
    });
    el.style.setProperty("--c", (i - 1) / 2);
    el.appendChild(vis);
  });

    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: Every letter rests at a medium weight in the dark ink colour.

Running cost: Changing font weight repaints the glyphs each frame; fine for one headline, too heavy for body text.

Wavy Baseline Ripple Through a Word

One large word bobs on a slow swell: each letter rises and tips a few degrees in turn, so a crest travels along the baseline from left to right. A thin drawn wave drifts underneath. The rest of the headline stays still, which keeps it readable and lets the moving word carry the mood of a seaside club or swim school.

<section class="wv-root" data-paused="false">
  <div class="wv-inner">
    <p class="wv-eyebrow">Kettle Cove Swim Club</p>
    <h2 class="wv-h">
      <span class="wv-sr">Saltwater mornings, all year.</span>
      <span class="wv-word" data-wv-split aria-hidden="true">Saltwater</span>
      <span class="wv-rest" aria-hidden="true">mornings, all year.</span>
    </h2>
    <span class="wv-sea" aria-hidden="true">
      <svg viewBox="0 0 400 14" preserveAspectRatio="none" focusable="false"><path d="M0 7 Q 25 1 50 7 T 100 7 T 150 7 T 200 7 T 250 7 T 300 7 T 350 7 T 400 7" fill="none" stroke="currentColor" stroke-width="2"/></svg>
    </span>
    <p class="wv-copy">We meet by the slipway at eight every Sunday, whatever the weather. Bring a bright hat; the flask of tea is on us.</p>
  </div>
</section>
<style>
/* Edit these first: colours, and how long one full cycle takes. */
.wv-root {
  --wv-bg: #d6ebe3;
  --wv-ink: #0d3b3a;
  --wv-muted: #2b5a56;
  --wv-accent: #0d3b3a;
  --wv-speed: 2.6s;
  position: relative;
  overflow: hidden;
  box-sizing: border-box;
  min-height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 72px 20px;
  background: var(--wv-bg);
  color: var(--wv-ink);
  font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
}
.wv-root *, .wv-root *::before, .wv-root *::after { box-sizing: border-box; }
.wv-inner { position: relative; z-index: 1; width: 100%; max-width: 1040px; }
.wv-eyebrow { margin: 0 0 22px; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 13px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--wv-muted); }
.wv-h { margin: 0; font-size: clamp(44px, 7.4vw, 92px); font-weight: 800; line-height: 1.02; letter-spacing: -.035em; }
.wv-copy { margin: 28px 0 0; max-width: 34em; font-family: "Inter Tight", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: clamp(16px, 1.5vw, 19px); line-height: 1.6; color: var(--wv-muted); }
.wv-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.wv-w { display: inline-block; white-space: nowrap; }
.wv-l { display: inline-block; }
/* Paused while scrolled out of view (the script sets data-paused). */
.wv-root[data-paused="true"] *, .wv-root[data-paused="true"] *::before, .wv-root[data-paused="true"] *::after { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
  .wv-root *, .wv-root *::before, .wv-root *::after { animation: none !important; }
}

.wv-root { background: linear-gradient(180deg, #e3f2ec 0%, var(--wv-bg) 70%, #c7e2d8 100%); }
.wv-word { display: block; font-size: 1.32em; letter-spacing: -.04em; }
.wv-rest { display: block; }
.wv-l {
  animation: wv-ripple var(--wv-speed) ease-in-out infinite;
  animation-delay: calc(var(--i) * 110ms - 3s); /* distance between crests */
}
.wv-sea { display: block; width: 100%; max-width: 560px; height: 14px; margin-top: 26px; overflow: hidden; color: var(--wv-accent); opacity: .45; }
.wv-sea svg { display: block; width: 200%; height: 100%; animation: wv-drift 7s linear infinite; }
@keyframes wv-ripple {
  0%, 100% { transform: translateY(0) rotate(0); }
  25% { transform: translateY(-.15em) rotate(-4deg); }
  50% { transform: translateY(0) rotate(0); }
  75% { transform: translateY(.06em) rotate(3deg); }
}
@keyframes wv-drift { to { transform: translateX(-50%); } }
</style>
<script>
(function () {
  var roots = document.querySelectorAll(".wv-root");
  roots.forEach(function (root) {
  // Split each marked element into word and letter spans. Screen readers get one plain copy.
  root.querySelectorAll("[data-wv-split]").forEach(function (el) {
    var text = el.textContent.trim();
    var hidden = el.getAttribute("aria-hidden") === "true";
    el.textContent = "";
    if (!hidden) {
      var sr = document.createElement("span");
      sr.className = "wv-sr";
      sr.textContent = text;
      el.appendChild(sr);
    }
    var vis = document.createElement("span");
    vis.setAttribute("aria-hidden", "true");
    var i = 0;
    text.split(" ").forEach(function (word, w) {
      if (w > 0) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span");
      ws.className = "wv-w";
      Array.from(word).forEach(function (ch) {
        var l = document.createElement("span");
        l.className = "wv-l";
        l.style.setProperty("--i", i++);
        l.textContent = ch;
        ws.appendChild(l);
      });
      vis.appendChild(ws);
    });
    el.style.setProperty("--c", (i - 1) / 2);
    el.appendChild(vis);
  });

    // Pause the animation while the section is off screen.
    if ("IntersectionObserver" in window) {
      new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          root.setAttribute("data-paused", e.isIntersecting ? "false" : "true");
        });
      }).observe(root);
    }
  });
})();
</script>

With reduced motion: The word sits level on its baseline and the drawn wave stays still.

Running cost: Nine letters and one SVG strip move with transforms only; the loop is cheap and runs on the compositor.

Performance notes

Text effects are cheap when they move letters with transform and fade them with opacity, because nothing around them has to be laid out again. Animating letter-spacing, font-size or font-weight does trigger layout; the effects that use them confine the change to one line in a fixed-size box so the rest of the page does not reflow.

Splitting a long paragraph into hundreds of letter spans adds many elements to the page. Split headlines by letter and paragraphs by word or by line, which looks the same at reading distance and keeps the element count low.

Questions

How do I animate each letter separately?

Wrap each letter in a span, give the spans an animation, and stagger animation-delay with a custom property such as --i set per span. Keep the original sentence in a visually hidden element and mark the letter spans aria-hidden.

Why do screen readers spell out my animated heading?

Because each letter is its own element, some screen readers pause between them. Put the readable sentence in an sr-only span and hide the animated copy with aria-hidden so the heading is announced once as a normal phrase.

How do I stop text animation from moving the page around?

Reserve the final size before the animation starts. Stack the finished text invisibly under the animated copy in the same grid cell, or give the box a fixed width and height, so the surrounding content never jumps.

Should text animations loop?

Rarely. A loop that keeps moving for more than five seconds needs a way to pause under WCAG 2.2.2, and it pulls attention from everything else. Play once on load, or loop slowly and stop when reduced motion is on.

More collections: Text Reveal and Scramble Animations, Typing Animations and Typewriter Effects, Text Animations for Websites. 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