Aduok Code

Wind-Scatter Scroll Text with GSAP & the Range API

Introduction

This article builds a scroll-driven "wind scatter" text effect: a heading holds its normal shape until the user scrolls, then each letter peels away and is carried off along a directional wind, tumbling in 3D with an optional lateral gust, before the whole thing reassembles if the user scrolls back up. There is no canvas and no particle system — every letter is a real, individually positioned DOM span, and the entire animation is a single GSAP timeline scrubbed by scroll position.

The effect rests on four mechanisms working together: the Range API, used to read the exact rendered bounding box of every character in a piece of live text so a letter can be pulled out of the flow without breaking layout — a small accessible DOM structure that keeps the real text readable to screen readers while a purely decorative overlay carries the animated glyphs — a per-character timeline built from a wind vector, a perpendicular scatter jitter, and independent X/Y/Z rotation — and a de-trended sine function layered on top of that straight-line flight so letters can wobble sideways without ever popping at the start or end of their motion.

What you will learn

How to measure individual characters of live, wrapped text using the Range API — how to keep a purely visual per-letter effect accessible to screen readers — how to convert an angle and strength into a 2D wind vector and apply it per character — how ordering modes (random, left-to-right, right-to-left, outward) and a randomness dial control stagger timing — how to build a gust wobble that is mathematically guaranteed to start and end at zero — and how to drive all of it from a scrubbed ScrollTrigger that rebuilds itself safely on resize.

01
Part One
Accessible DOM Structure

Step 1 — Three Layers per Element

Every animated heading is rebuilt into three stacked children the moment the script runs: a visually-hidden span holding the original text (so screen readers and text search see the real sentence, untouched), a hidden placeholder that keeps the same text in the DOM purely so its rendered geometry can be measured, and an absolutely-positioned overlay that will receive one span per character. Nothing in the overlay or placeholder is exposed to assistive technology — both carry aria-hidden="true" — so the decorative letter-flinging never gets read aloud twice.

JS
function buildStructure(el, text) {
  if (getComputedStyle(el).position === 'static') {
    el.style.position = 'relative';
  }
  el.textContent = '';

  const screenReaderText = document.createElement('span');
  screenReaderText.className = 'visually-hidden';
  screenReaderText.textContent = text;
  el.appendChild(screenReaderText);

  const placeholder = document.createElement('span');
  placeholder.setAttribute('aria-hidden', 'true');
  placeholder.style.cssText = 'visibility:hidden; pointer-events:none;';
  placeholder.textContent = text;
  el.appendChild(placeholder);

  const overlay = document.createElement('span');
  overlay.setAttribute('aria-hidden', 'true');
  overlay.style.cssText =
    'position:absolute; top:0; left:0; width:100%; height:100%; overflow:visible;';
  el.appendChild(overlay);

  return { placeholder, overlay };
}
LayerVisible?Purpose
Screen-reader spanNo (clipped, 1×1px)Keeps the real sentence in the accessibility tree
PlaceholderNo (visibility:hidden)Holds natural layout so Range API measurements are correct
OverlayYesReceives one absolutely-positioned span per character, animated by GSAP
02
Part Two
Measuring Characters

Step 2 — Reading Every Letter's Real Position

To scatter a letter without breaking the paragraph's wrapping, the code needs each character's exact rendered box — including which line it wrapped to. A document.createRange() spanning a single character inside the hidden placeholder's text node, followed by getBoundingClientRect(), gives that box for free, in the browser's own layout engine. Each measured character becomes an absolutely-positioned span at that exact left/top offset inside the overlay, so before any animation runs the overlay is pixel-identical to the placeholder underneath it.

JS
function measureAndCreateChars(el, text, placeholder, overlay) {
  const containerRect = el.getBoundingClientRect();
  const textNode = placeholder.firstChild;
  const chars = [];

  for (let i = 0; i < text.length; i++) {
    if (text[i] === ' ') continue;

    const range = document.createRange();
    range.setStart(textNode, i);
    range.setEnd(textNode, i + 1);
    const box = range.getBoundingClientRect();

    const span = document.createElement('span');
    span.textContent = text[i];
    span.classList.add('fly-char');
    span.style.cssText = [
      'position:absolute',
      `left:${box.left - containerRect.left}px`,
      `top:${box.top - containerRect.top}px`,
      `width:${box.width}px`,
      `height:${box.height}px`,
    ].join(';');

    span._x = box.left - containerRect.left;
    overlay.appendChild(span);
    chars.push(span);
  }

  // Normalise x across ALL characters (0 = leftmost, 1 = rightmost) so
  // ordering modes sweep correctly even across wrapped, multi-line text.
  const xs = chars.map((c) => c._x);
  const xMin = Math.min(...xs);
  const xRange = Math.max(...xs) - xMin || 1;
  chars.forEach((c) => { c._normX = (c._x - xMin) / xRange; });

  return chars;
}
Why the Range API instead of measuring words?

Measuring per-character rather than per-word means the effect works identically whether the heading is one word or a full paragraph, and it survives font loading, responsive resizing, and line wrapping without any special-casing — a wrapped line is just a set of characters whose y offset happens to differ, which the rest of the code never needs to know about.

03
Part Three
The Wind Vector

Step 3 — Turning an Angle into Motion

Direction is authored as a single human-friendly value — windAngle in degrees, where 0 is directly right and 90 is straight up — and converted once into a unit vector. The Y component is negated because CSS Y grows downward while the authored angle assumes a conventional upward-positive math convention, so a 90° "wind" visually blows upward rather than down. Every character multiplies that shared direction by windStrength and then adds its own random perpendicular scatter, so letters travel roughly together but never along an identical line.

JS
const windRadians = (windAngle * Math.PI) / 180;
const windX =  Math.cos(windRadians);
const windY = -Math.sin(windRadians); // negate CSS Y for a natural "upward" wind
const perpX =  Math.sin(windRadians); // perpendicular axis, reused for gust drift
const perpY =  Math.cos(windRadians);

const scatterAngle = randomBetween(0, Math.PI * 2);
const scatterDist  = randomBetween(0, scatter);

const flightX = windX * windStrength + Math.cos(scatterAngle) * scatterDist;
const flightY = windY * windStrength + Math.sin(scatterAngle) * scatterDist;
const flightZ = randomBetween(-depth, depth);

const rotationX = randomBetween(-maxRotation, maxRotation);
const rotationY = randomBetween(-maxRotation * 0.7, maxRotation * 0.7);
const rotationZ = randomBetween(-maxRotation * 0.3, maxRotation * 0.3);

The same perpendicular axis (perpX, perpY) used to scatter each letter sideways around the shared wind direction is reused later for the gust wobble — one 90°-rotated vector serves both a one-time random offset and a continuous sine drift, so the two effects always read as coming from the same "breeze" rather than two unrelated forces.

04
Part Four
Stagger & Ordering Modes

Step 4 — Deciding Which Letter Leaves First

Each character's start time inside the shared timeline is a value between 0 and stagger. In "random" order that value is just a random pick; in "ltr" / "rtl" it is derived directly from the character's normalised x position, so the scatter visibly sweeps across the line; in "outward" the formula is inverted around the midpoint, so the two ends of a line leave first and the center holds longest. A randomness dial between 0 and 1 blends the ordered value with a random one, letting a design lean anywhere between a crisp directional sweep and full chaos.

JS
function getCharStartTime(char, order, stagger, randomness) {
  const x = char._normX;
  let 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 randomBetween(0, stagger);
  }

  return ordered * (1 - randomness) + randomBetween(0, stagger) * randomness;
}
order valueLeaves firstLeaves last
randomno pattern — fully shuffledno pattern — fully shuffled
ltrleftmost characterrightmost character
rtlrightmost characterleftmost character
outwardboth line edges at oncethe exact horizontal center
05
Part Five
The Gust Wobble

Step 5 — A Sine Wave That Can't Pop

A naive sine offset added on top of a straight flight path will usually not be zero at the start or end of the tween, which shows up as a visible snap the instant the animation begins or finishes. The fix is to sample the sine at both endpoints of the character's travel (t = 0 and t = 1) and subtract the straight line between those two sampled values from the whole curve. The result is a wobble with exactly the same shape, but mathematically pinned to zero at both ends — regardless of frequency, phase, or amplitude — so it can be layered onto any flight path safely.

JS
// De-trend the sine so it's exactly 0 at both t=0 and t=1, regardless of
// phase: subtract the linear ramp between the two endpoint values.
const s0 = Math.sin(phase);
const s1 = Math.sin(Math.PI * gustFrequency + phase);

const gustOffset = (t) =>
  amp * (Math.sin(Math.PI * gustFrequency * t + phase) - s0 - t * (s1 - s0));

const poseAt = (t) => {
  const s = reverse ? 1 - t : t;
  return {
    x: s * flightX + perpX * gustOffset(t),
    y: s * flightY + perpY * gustOffset(t),
    z: s * flightZ,
    rotationX: rotationX * s,
    rotationY: rotationY * s,
    rotationZ: rotationZ * s,
    opacity: clamp01((1 - s) / 0.6),
  };
};
Why drive this with a proxy object instead of a normal tween?

gustOffset depends on t in a way GSAP's built-in x/y/rotation tweening can't express directly, so the timeline instead tweens a plain { t: 0 } proxy object from 0 to 1 and calls gsap.set() on every onUpdate with the freshly computed pose. This keeps the wobble fully custom while still letting GSAP own easing, duration, and — critically — scroll scrubbing.

06
Part Six
Scroll-Scrubbing & Resize

Step 6 — Wiring the Timeline to Scroll

The finished per-character timeline is built once, paused, and handed to ScrollTrigger with scrub: 1 — so scroll position, not time, drives playback, and scrubbing back up runs the whole scatter in reverse for free. Because font metrics and container width can change after first paint, a ResizeObserver watches the element and, on any real size change, kills the old ScrollTrigger and timeline and rebuilds both from scratch, debounced by 200ms so rapid resize events don't thrash the DOM.

JS
function setup() {
  if (scrollTrigger) scrollTrigger.kill();
  if (timeline) timeline.kill();
  overlay.innerHTML = '';

  const chars = measureAndCreateChars(el, text, placeholder, overlay);
  if (!chars.length) return;

  timeline = buildTimeline(chars, config);
  const startPercent = Math.round((config.startY ?? (config.reverse ? 0.85 : 0.65)) * 100);

  scrollTrigger = ScrollTrigger.create({
    trigger: el,
    start: `top ${startPercent}%`,
    end: config.reverse ? 'top 20%' : 'bottom top',
    scrub: 1,
    animation: timeline,
  });
}

setup();

let resizeTimer, lastWidth = 0, lastHeight = 0;
const resizeObserver = new ResizeObserver(([entry]) => {
  const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
  if (width === lastWidth && height === lastHeight) return;
  lastWidth = width; lastHeight = height;

  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(setup, 200);
});
resizeObserver.observe(el);

scrub: 1 rather than scrub: true adds a small easing lag between scroll position and playback, which reads as physical inertia — the letters feel like they're being dragged by momentum rather than snapped to a scrollbar, at almost no extra code.

Tuning Reference

ConstantDefaultEffect
windAngle25°Direction of travel — 0 = right, 90 = up, 180 = left
windStrength400pxHow far along the wind direction each letter travels
scatter80pxRandom perpendicular jitter layered on top of the shared wind
maxRotation360°Max rotation on any axis — Y and Z are scaled down from this base
stagger0.5Fraction of the timeline over which per-letter start times spread
depth120pxMax Z translation — needs transformPerspective to read as 3D
orderrandomControls which letters start first: random / ltr / rtl / outward
randomness0Blends ordered stagger with random stagger, 0 = fully ordered
gustiness0pxAmplitude of the perpendicular sine wobble during flight, 0 disables it
gustFrequency1Number of sine cycles completed over one letter's flight
gustPhaseSpread1Blends a synchronized gust (0) with a per-character phase-shifted one (1)
animationDuration1Fraction of the scroll range the animation is stretched to fit inside

Full Source Code

The complete effect is a single self-contained HTML document — GSAP and ScrollTrigger loaded from a CDN, no build step, no framework. The DOM structure, measurement, timeline-building, and ScrollTrigger wiring described above follow directly below the markup. Open it in any modern browser; there is nothing to install.

HTML — wind-scatter-text.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/wind-scatter-text.html

One HTML file, a dozen small functions, zero dependencies beyond GSAP. The entire wind-scatter effect — accessible DOM structure, Range-API measurement, a directional wind vector, four ordering modes, a de-trended gust wobble, and a resize-safe scrubbed ScrollTrigger — is well under 300 lines of vanilla JavaScript.

Want to see it in action?Watch the full build on YouTube