Copied!
Text

Wind scatter text

A headline whose letters blow away like leaves in the wind as you scroll, tumbling in 3D and fading, then fly back when you scroll up.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

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

Prompt for your AI assistant

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

Build "Wind scatter text" for my site using the Enrichr Library element at https://enrichr.dev/elements/wind-scatter-text.html.

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

Settings:
- text: Type flies away, blown by a breeze
- autoplay: on
- speed: 1
- mode: scatter
- windAngle: 18
- windStrength: 550
- scatter: 100
- maxRotation: 480
- stagger: 0.7
- depth: 160
- order: random
- randomness: 0
- gustiness: 0
- gustFrequency: 1
- gustPhaseSpread: 1
- animationDuration: 0.6
- startY: 0.5
- easing: auto
- seed: 42
- background: #fbf8f1
- color: #0c1e18

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

The same spec as JSON, for tools that read it directly: /elements/wind-scatter-text.json.

Code

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

HTML

<div class="ef-wst">
  <h2 class="ef-wst__t">Type flies away, blown by a breeze</h2>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-wst { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 24rem; display: flex; align-items: center; justify-content: center; padding: 2.5rem 2rem; overflow: hidden; border-radius: 1rem; background: #fbf8f1; color: #0c1e18; container-type: inline-size; }
.ef-wst .ef-wst__t { position: relative; box-sizing: border-box; width: 100%; max-width: 56.25rem; margin: 0; padding: 0; opacity: 1; color: inherit; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(2.25rem, 9cqw, 7.5rem); font-weight: 800; line-height: 0.95; letter-spacing: -0.02em; text-align: left; text-transform: none; }
.ef-wst .ef-wst__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.ef-wst .ef-wst__ph { visibility: hidden; pointer-events: none; user-select: none; }
.ef-wst .ef-wst__ov { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.ef-wst .ef-wst__c { position: absolute; white-space: nowrap; will-change: transform, opacity; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Wind scatter text: a headline whose letters are blown away like leaves as you scroll, then fly back when you scroll up.
// Each letter is measured where it sits (Range API) and given an absolutely placed twin. A seeded random generator (sfc32,
// seed 42) gives each letter a start time inside the stagger window (random, left to right, right to left or outward), a
// duration near one second, a push along the wind angle plus a random sideways scatter, a depth, and turns on all three
// axes; letters ease out with power3.in and fade. Gustiness adds a de-trended sine wobble across the wind. The timeline is
// scrubbed by scroll exactly like a ScrollTrigger with scrub: 1 (start when the text's top reaches startY of the viewport,
// end when its bottom leaves the top; each change restarts a one second power3.out catch-up). With autoplay on, a virtual
// scroll plays the scatter forward and back on a loop so it shows without scrolling. Reduced motion: autoplay shows the
// still text, scroll mode only fades the text, as in the original. The loop pauses off screen and in hidden tabs.
// mount(root, { text, autoplay (on | off), speed (autoplay speed), mode (scatter | reveal), windAngle (deg), windStrength (px),
//               scatter (px), maxRotation (deg), stagger, depth (px), order (random | ltr | rtl | outward), randomness,
//               gustiness (px), gustFrequency, gustPhaseSpread, animationDuration (share of the scroll), startY (0-1),
//               easing (auto | none | power2.in | power3.in | power3.out | power2.inOut | elastic), seed, background, color })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-wst");
  var el = root.querySelector(".ef-wst__t");
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, resizeTimer = 0;
  var raw = "", sr = null, ph = null, ov = null, chars = [], total = 1, animEnd = 1, built = false;
  var prog = 0, scrub = null, auto = 0, lastW = 0, lastH = 0, pending = false;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function clamp01(v) { return Math.max(0, Math.min(1, v)); }
  function lerp(a, b, t) { return a + (b - a) * t; }

  // ---- the original's seeded random: splitmix32 seeds sfc32, warmed up 12 times ----
  function sfc32(a, b, c, d) {
    return function () {
      a |= 0; b |= 0; c |= 0; d |= 0;
      var t = (a + b | 0) + d | 0;
      d = d + 1 | 0;
      a = b ^ b >>> 9;
      b = c + (c << 3) | 0;
      c = c << 21 | c >>> 11;
      c = c + t | 0;
      return (t >>> 0) / 4294967296;
    };
  }
  function seededRandom(seed) {
    var s = seed >>> 0;
    function splitmix32() {
      s = s + 0x9e3779b9 | 0;
      var t = s ^ s >>> 16;
      t = Math.imul(t, 0x21f0aaad);
      t = t ^ t >>> 15;
      t = Math.imul(t, 0x735a2d97);
      return (t ^ t >>> 15) >>> 0;
    }
    var f = sfc32(splitmix32(), splitmix32(), splitmix32(), splitmix32());
    for (var i = 0; i < 12; i++) f();
    return f;
  }
  var r = seededRandom(42);
  function rand(min, max) { return min + r() * (max - min); }

  // ---- GSAP eases, same maths ----
  function elasticOut(a, period) {
    var p1 = a >= 1 ? a : 1, p2 = (period || 0.3) / (a < 1 ? a : 1), p3 = p2 / (Math.PI * 2) * (Math.asin(1 / p1) || 0);
    p2 = Math.PI * 2 / p2;
    return function (p) { return p === 1 ? 1 : p1 * Math.pow(2, -10 * p) * Math.sin((p - p3) * p2) + 1; };
  }
  var EASES = {
    none: function (p) { return p; },
    "power2.in": function (p) { return p * p * p; },
    "power3.in": function (p) { return p * p * p * p; },
    "power3.out": function (p) { return 1 - Math.pow(1 - p, 4); },
    "power2.inOut": function (p) { return p < 0.5 ? Math.pow(p * 2, 3) / 2 : 1 - Math.pow((1 - p) * 2, 3) / 2; },
    elastic: elasticOut(1, 0.7) // elastic.out(1, 0.7), the reveal example in the original
  };
  function power1Out(p) { return 1 - (1 - p) * (1 - p); }
  function power3Out(p) { return 1 - Math.pow(1 - p, 4); }

  // ---- DOM: hidden real text, an invisible placeholder that keeps the size, and an overlay of letter spans ----
  function structure() {
    raw = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim();
    el.textContent = "";
    sr = document.createElement("span"); sr.className = "ef-wst__sr"; sr.textContent = raw; el.appendChild(sr);
    ph = document.createElement("span"); ph.className = "ef-wst__ph"; ph.setAttribute("aria-hidden", "true"); ph.textContent = raw; el.appendChild(ph);
    ov = document.createElement("span"); ov.className = "ef-wst__ov"; ov.setAttribute("aria-hidden", "true"); el.appendChild(ov);
  }
  function measure() {
    ov.textContent = "";
    chars = [];
    var box = el.getBoundingClientRect(), node = ph.firstChild;
    if (!node) return;
    for (var i = 0; i < raw.length; i++) {
      if (raw[i] === " ") continue;
      var range = document.createRange();
      range.setStart(node, i); range.setEnd(node, i + 1);
      var q = range.getBoundingClientRect();
      var s = document.createElement("span");
      s.className = "ef-wst__c";
      s.textContent = raw[i];
      s.style.cssText = "left:" + (q.left - box.left) + "px;top:" + (q.top - box.top) + "px;width:" + q.width + "px;height:" + q.height + "px;line-height:" + q.height + "px";
      ov.appendChild(s);
      chars.push({ el: s, x: q.left - box.left, last: -1 });
    }
    // 0 = leftmost letter on any line, 1 = rightmost, so ordered sweeps work on multi-line text
    var xMin = Infinity, xMax = -Infinity;
    chars.forEach(function (c) { xMin = Math.min(xMin, c.x); xMax = Math.max(xMax, c.x); });
    var xr = (xMax - xMin) || 1;
    chars.forEach(function (c) { c.nx = (c.x - xMin) / xr; });
  }

  function charStartTime(c, n, order, stagger, randomness) {
    if (n <= 1) return 0;
    var x = c.nx, ordered;
    switch (order) {
      case "ltr": ordered = x * stagger; break;
      case "rtl": ordered = (1 - x) * stagger; break;
      case "outward": ordered = (1 - Math.abs(x - 0.5) * 2) * stagger; break;
      default: return rand(0, stagger);
    }
    return ordered * (1 - randomness) + rand(0, stagger) * randomness;
  }

  // ---- the timeline, built exactly like the original's, but stored as numbers to evaluate at any time ----
  function timeline() {
    r = seededRandom(Math.round(opts.seed));
    var reverse = opts.mode === "reveal", p = opts;
    var rad = p.windAngle * Math.PI / 180, windX = Math.cos(rad), windY = -Math.sin(rad), perpX = Math.sin(rad), perpY = Math.cos(rad);
    var custom = EASES[p.easing] || null;
    var sharedAmp = p.gustiness > 0 ? rand(0.1, 1.0) * p.gustiness * (r() > 0.5 ? 1 : -1) : 0;
    var end = 0;
    chars.forEach(function (c, i) {
      var startTime = charStartTime(c, chars.length, p.order, p.stagger, p.randomness);
      var duration = rand(1 - p.randomness * 0.5, 1 + p.randomness * 0.5);
      var scatterAngle = rand(0, Math.PI * 2), scatterDist = rand(0, p.scatter);
      var syncPhase = Math.PI * p.gustFrequency * startTime;
      var indexPhase = i / Math.max(1, chars.length - 1) * Math.PI * 2;
      var phase = lerp(syncPhase, indexPhase, p.gustPhaseSpread);
      c.start = startTime; c.dur = duration;
      c.fx = windX * p.windStrength + Math.cos(scatterAngle) * scatterDist;
      c.fy = windY * p.windStrength + Math.sin(scatterAngle) * scatterDist;
      c.fz = rand(-p.depth, p.depth);
      c.rx = rand(-p.maxRotation, p.maxRotation);
      c.ry = rand(-p.maxRotation * 0.7, p.maxRotation * 0.7);
      c.rz = rand(-p.maxRotation * 0.3, p.maxRotation * 0.3);
      c.gust = p.gustiness > 0;
      if (c.gust) {
        var individualAmp = rand(0.1, 1.0) * p.gustiness * (r() > 0.5 ? 1 : -1);
        c.amp = lerp(sharedAmp, individualAmp, p.gustPhaseSpread);
        c.phase = phase;
        c.s0 = Math.sin(phase);
        c.s1 = Math.sin(Math.PI * p.gustFrequency + phase);
        c.ease = custom || EASES["power3.in"];
      } else {
        c.ease = custom || (reverse ? EASES["power3.out"] : EASES["power3.in"]);
      }
      c.last = -1;
      end = Math.max(end, startTime + duration);
    });
    total = end || 1;
    // the original stretches the timeline so the letters finish within animationDuration of the scroll
    var ad = p.animationDuration;
    if (ad > 0 && ad < 1) total = total / ad;
    animEnd = (end || 1) / total;
  }

  // ---- render every letter at timeline progress pr ----
  function render(pr) {
    var reverse = opts.mode === "reveal", T = pr * total, gf = opts.gustFrequency;
    if (reduce) {
      // the original's reduced-motion timeline: the whole heading fades over the scroll, letters stay put
      var e = power1Out(clamp01(pr));
      el.style.opacity = opts.autoplay === "on" ? 1 : reverse ? e : 1 - e;
      return;
    }
    for (var i = 0; i < chars.length; i++) {
      var c = chars[i], local = clamp01((T - c.start) / c.dur);
      if (local === c.last) continue; // nothing changed for this letter
      c.last = local;
      var t = c.ease(local), x, y, z, rx, ry, rz, op;
      if (c.gust) {
        var s = reverse ? 1 - t : t;
        var g = c.amp * (Math.sin(Math.PI * gf * t + c.phase) - c.s0 - t * (c.s1 - c.s0)); // de-trended: 0 at both ends
        x = s * c.fx + Math.sin(opts.windAngle * Math.PI / 180) * g;
        y = s * c.fy + Math.cos(opts.windAngle * Math.PI / 180) * g;
        z = s * c.fz; rx = c.rx * s; ry = c.ry * s; rz = c.rz * s;
        op = clamp01((1 - s) / 0.6);
      } else {
        var k = reverse ? 1 - t : t; // fromTo(scattered -> natural) on reveal, (natural -> scattered) on scatter
        x = c.fx * k; y = c.fy * k; z = c.fz * k; rx = c.rx * k; ry = c.ry * k; rz = c.rz * k;
        op = 1 - k;
      }
      // GSAP's transform order: perspective, translate3d, rotate (z), rotateY, rotateX
      c.el.style.transform = "perspective(500px) translate3d(" + x + "px," + y + "px," + z + "px) rotate(" + rz + "deg) rotateY(" + ry + "deg) rotateX(" + rx + "deg)";
      c.el.style.opacity = op;
    }
  }

  function build() {
    if (destroyed) return;
    structure();
    el.style.opacity = "";
    if (!reduce) { measure(); timeline(); }
    else { ph.style.visibility = "visible"; ph.setAttribute("aria-hidden", "true"); }
    built = true;
    render(prog);
  }

  // ---- scroll progress, like ScrollTrigger: start "top startY%", end "bottom top" (scatter) or "top 20%" (reveal) ----
  function scrollProgress() {
    var b = el.getBoundingClientRect(), vh = window.innerHeight || document.documentElement.clientHeight || 1;
    var start = vh * Math.round(opts.startY * 100) / 100;
    var endTop = opts.mode === "reveal" ? vh * 0.2 : -b.height;
    return clamp01((start - b.top) / Math.max(1, start - endTop));
  }
  // scrub: 1 -> every new target restarts a 1 s power3.out tween from where the progress is now
  function aim(v, now) {
    if (scrub ? Math.abs(scrub.to - v) < 1e-6 : Math.abs(prog - v) < 1e-6) return;
    scrub = { from: prog, to: v, t0: now };
  }
  // like ScrollTrigger, scroll events are batched and handled once per frame, after that frame's render
  function onScroll() { pending = true; }

  // autoplay: a virtual scroll that waits, scatters, holds, and scrolls back, on a loop
  function autoTarget(dt) {
    var sp = Math.max(0.05, opts.speed), A = 1.0, R = 2.4, B = 0.8;
    auto = (auto + dt * sp) % (A + R + B + R);
    var t = auto, v;
    if (t < A) v = 0;
    else if (t < A + R) v = (t - A) / R;
    else if (t < A + R + B) v = 1;
    else v = 1 - (t - A - R - B) / R;
    return v * Math.min(1, animEnd + 0.02);
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden || !built) return;
    // GSAP renders the scrub tween first, then ScrollTrigger restarts it for the new target, so a restarted tween first
    // moves on the next frame (about 6% of the way per frame while the target keeps moving)
    if (scrub) {
      var p = Math.min(1, (now - scrub.t0) / 1000);
      prog = scrub.from + (scrub.to - scrub.from) * power3Out(p);
      if (p >= 1) { prog = scrub.to; scrub = null; }
      render(prog);
    }
    if (opts.autoplay === "on" && !reduce) aim(autoTarget(dt), now);
    else if (pending) { pending = false; aim(scrollProgress(), now); }
  }

  function update(partial) {
    var prev = opts;
    opts = Object.assign({ text: "Type flies away, blown by a breeze", autoplay: "on", speed: 1, mode: "scatter", windAngle: 18, windStrength: 550,
      scatter: 100, maxRotation: 480, stagger: 0.7, depth: 160, order: "random", randomness: 0, gustiness: 0, gustFrequency: 1, gustPhaseSpread: 1,
      animationDuration: 0.6, startY: 0.5, easing: "auto", seed: 42, background: "#fbf8f1", color: "#0c1e18" }, opts, partial || {});
    ["speed", "windAngle", "windStrength", "scatter", "maxRotation", "stagger", "depth", "randomness", "gustiness", "gustFrequency", "gustPhaseSpread", "animationDuration", "startY", "seed"].forEach(function (k) {
      opts[k] = num(opts[k], { speed: 1, windAngle: 18, windStrength: 550, scatter: 100, maxRotation: 480, stagger: 0.7, depth: 160, randomness: 0, gustiness: 0, gustFrequency: 1, gustPhaseSpread: 1, animationDuration: 0.6, startY: 0.5, seed: 42 }[k]);
    });
    wrap.style.background = opts.background;
    wrap.style.color = opts.color;
    if (destroyed || !built) return;
    if (opts.text !== prev.text) { build(); }
    else if (!reduce) { timeline(); render(prog); }
    if (opts.autoplay !== prev.autoplay) { auto = 0; prog = 0; scrub = null; if (opts.autoplay !== "on") prog = scrollProgress(); render(prog); }
  }

  function start() {
    if (destroyed) return;
    build();
    if (opts.autoplay !== "on") { prog = scrollProgress(); render(prog); }
  }

  update(o);
  if (document.fonts && document.fonts.ready) {
    // measure after the fonts land so every letter box uses the right metrics
    document.fonts.ready.then(start);
    if (document.fonts.load) document.fonts.load("800 64px Parkinsans").then(function () { if (built && !destroyed) { build(); } }, function () {});
  } else start();
  window.addEventListener("scroll", onScroll, { passive: true, capture: true });
  window.addEventListener("resize", onScroll, { passive: true });
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function (entries) {
      var b = entries[0].contentRect;
      if (b.width === lastW && b.height === lastH) return;
      lastW = b.width; lastH = b.height;
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(function () { if (built && !destroyed) { build(); onScroll(); } }, 200); // the original's 200 ms debounce
    });
    ro.observe(el);
  }
  last = performance.now(); raf = requestAnimationFrame(loop);
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); clearTimeout(resizeTimer);
      window.removeEventListener("scroll", onScroll, { capture: true });
      window.removeEventListener("resize", onScroll);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Type flies away, blown by a breeze","autoplay":"on","speed":1,"mode":"scatter","windAngle":18,"windStrength":550,"scatter":100,"maxRotation":480,"stagger":0.7,"depth":160,"order":"random","randomness":0,"gustiness":0,"gustFrequency":1,"gustPhaseSpread":1,"animationDuration":0.6,"startY":0.5,"easing":"auto","seed":42,"background":"#fbf8f1","color":"#0c1e18"});

How it works

The real heading text stays in the page for screen readers, an invisible copy holds its size, and each letter gets an absolutely placed twin measured with the Range API so it sits exactly on its glyph. A seeded random generator (sfc32 seeded by splitmix32, seed 42) gives every letter a start time inside the stagger window, a duration of about one second, a push along the wind angle plus a random sideways scatter, a depth and a turn on all three axes. Each letter eases out on a power3.in curve with perspective 500px, in GSAP's transform order, and fades. Gustiness adds a sine wobble across the wind that is pinned to zero at both ends of the flight. The whole timeline is stretched so the letters finish within the chosen share of the scroll, then scrubbed like a ScrollTrigger with scrub 1: it starts when the text's top reaches half the viewport and ends when its bottom leaves the top, and every change restarts a one second power3.out catch-up, which gives the soft lag. Autoplay drives the same scrub with a virtual scroll that waits, scatters, holds and scrolls back.

Accessibility and performance

The full sentence stays in the heading as visually hidden text and the moving letters are hidden from screen readers, so it reads normally. With prefers-reduced-motion no letter moves: in autoplay the text stays still, and in scroll mode the heading only fades as you scroll, which is what the original does. Nothing needs keyboard input.

Each letter is one span moved with a CSS transform and opacity, so the browser composites them without layout. A letter is only restyled when its own progress changes, and nothing runs while the scrub is at rest, off screen or in a hidden tab. Letters are measured once after the fonts load and again only when the heading's size changes, with a 200 ms pause like the original.

SectionText
TechniquesText animation, Scroll-driven, CSS 3D, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byGSAP wind-blown text by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

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

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

Licence and provenance

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

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

Team card fan heroA "Small team, big results" hero where a fanned row of photo cards drops in, floats, tilts under the pointer and fans out as you scroll away. Kinetic type experimentsFive experimental text studies: a breathing variable weight, matrix rain that locks into your word, a blurred word morph, a scroll-linked colour split and a gravity drop. Kinetic type revealsNine ways for a line of type to arrive, from scramble and split-flap to ink underline and 3D flip, picked from one menu and replayed each time it scrolls into view.

FAQ

How do I make it follow the scroll instead of looping?

Set Autoplay to Off. The letters then scatter as the heading scrolls up the screen and fly back when you scroll down, exactly like the original pen.

Does it need GSAP, ScrollTrigger or SplitText?

No. The timeline is plain numbers per letter evaluated at any time, the scrub is a one second power3.out tween that restarts whenever the scroll target changes, and the split is the Range API measuring each letter.

How do I make the text appear instead of disappear?

Set Scroll direction to Scrolling reveals the text. The letters start scattered and settle into place as you scroll, ending when the heading's top reaches 20 percent of the viewport.

Why is the layout the same every time?

The randomness is seeded. Change the Random seed to get a different but repeatable scatter, so the effect looks the same for every visitor.