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.
| Section | Text |
|---|---|
| Techniques | Text animation, Scroll-driven, CSS 3D, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | GSAP 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.
More elements
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.