← Text effects

Falling Letters

One by one the letters wobble loose and tumble to the bottom of the box, bouncing and rolling into a heap. Grab any letter to drag it around or throw it back on the pile. Loop it and they fly home and fall again.

Gravity always wins

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-falling-letters to a box holding a short line of text, and give the box a height: the letters land on its bottom edge.

Options

Add any of these attributes to the same element.

data-delayWait before the first letter falls. Default 600 ms.
data-gravityHow hard they fall: 0.5 is floaty, 2 heavy. Default 1.
data-bounceHow much they bounce off the walls and floor, from 0 to 1. Default 0.3.
data-loopAfter a while the letters fly back into place and fall again.
data-holdHow long the pile stays before going home when looping. Default 4000 ms.
data-autoplayNow and then a letter gets picked up and tossed by itself, pausing while someone drags.

Letters can be dragged with a mouse or a finger. The box clips them, so they never escape onto the page.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Falling Letters: the letters break loose one by one and tumble to the
    // bottom of the box, piling up on each other. Grab any letter (mouse or
    // finger) and drag or throw it around the pile.
    // Usage: <div data-falling-letters style="height:320px">Gravity wins</div>
    // The element is the box the letters fall into; give it a height (it gets
    // at least 5 lines of text otherwise).
    // Optional: data-delay (ms before the first letter falls; default 600),
    // data-gravity (multiplier; default 1), data-bounce (0 to 1; default 0.3),
    // data-loop (after a while the letters fly back home and fall again),
    // data-hold (ms the pile stays before going home when looping; default 4000),
    // data-autoplay (now and then a letter gets picked up and tossed by itself,
    // pausing while someone drags).
    var DEFAULTS = { delay: 600, gravity: 1, bounce: 0.3, hold: 4000 };

    var css =
      '.fll-box{position:relative;overflow:hidden}' +
      '.fll-word{display:inline-block;white-space:nowrap}' +
      '.fll-ch{display:inline-block;will-change:transform;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}' +
      '.fll-ch.fll-held{cursor:grabbing}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function split(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var line = document.createElement('span');
      line.setAttribute('aria-hidden', 'true');
      var bodies = [];
      text.split(' ').forEach(function (w, i) {
        if (i) line.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'fll-word';
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'fll-ch';
          s.textContent = c;
          word.appendChild(s);
          bodies.push({ el: s, x: 0, y: 0, px: 0, py: 0, a: 0, va: 0, free: false });
        });
        line.appendChild(word);
      });
      el.appendChild(line);
      return bodies;
    }

    function setup(el) {
      var d = el.dataset;
      var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
      var gravity = d.gravity !== undefined ? +d.gravity : DEFAULTS.gravity;
      var bounce = d.bounce !== undefined ? +d.bounce : DEFAULTS.bounce;
      var hold = +d.hold || DEFAULTS.hold;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';

      el.classList.add('fll-box');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var bodies = split(el);
      if (reduced() || !bodies.length) return;

      var W = 0, H = 0, fs = 16;
      function measure() {
        fs = parseFloat(getComputedStyle(el).fontSize);
        if (el.clientHeight < fs * 4) el.style.minHeight = (fs * 5.5) + 'px';
        W = el.clientWidth; H = el.clientHeight;
        bodies.forEach(function (b) {
          var w = b.el.offsetWidth, h = b.el.offsetHeight;
          b.hx = b.el.offsetLeft + w / 2;
          b.hy = b.el.offsetTop + h / 2;
          b.r = Math.max(w, fs * 0.72) / 2 * 0.95;
          if (!b.free && !b.going) { b.x = b.px = b.hx; b.y = b.py = b.hy; }
        });
      }
      measure();
      new ResizeObserver(measure).observe(el);

      function render() {
        bodies.forEach(function (b) {
          b.el.style.transform = 'translate(' + (b.x - b.hx).toFixed(1) + 'px,' + (b.y - b.hy).toFixed(1) + 'px) rotate(' + b.a.toFixed(3) + 'rad)';
        });
      }

      // ---- physics (Verlet, fixed 120 Hz steps) ----
      var DT = 1 / 120;
      var held = null, holdX = 0, holdY = 0;
      function step() {
        var g = fs * 70 * gravity * DT * DT;
        bodies.forEach(function (b) {
          if (!b.free) return;
          if (b === held) {
            b.px = b.x; b.py = b.y;
            b.x += (holdX - b.x) * 0.5; b.y += (holdY - b.y) * 0.5;
            return;
          }
          var vx = (b.x - b.px) * 0.998, vy = (b.y - b.py) * 0.998;
          b.px = b.x; b.py = b.y;
          b.x += vx; b.y += vy + g;
          b.a += b.va;
          b.ground = false;
        });
        for (var it = 0; it < 4; it++) {
          for (var i = 0; i < bodies.length; i++) {
            var p = bodies[i];
            if (!p.free) continue;
            for (var j = i + 1; j < bodies.length; j++) {
              var q = bodies[j];
              if (!q.free) continue;
              var dx = q.x - p.x, dy = q.y - p.y, min = p.r + q.r;
              var dd = dx * dx + dy * dy;
              if (dd >= min * min || dd === 0) continue;
              var dist = Math.sqrt(dd), push = (min - dist) / dist;
              var wp = p === held ? 0 : q === held ? 1 : 0.5, wq = 1 - wp;
              if (p === held) wq = 1;
              p.x -= dx * push * wp; p.y -= dy * push * wp;
              q.x += dx * push * wq; q.y += dy * push * wq;
              if (dy > 0) p.ground = true; else q.ground = true;
            }
          }
          bodies.forEach(function (b) {
            if (!b.free) return;
            var vx = b.x - b.px, vy = b.y - b.py;
            if (b.y > H - b.r) { b.y = H - b.r; b.py = b.y + vy * bounce; b.px = b.x - vx * 0.85; b.ground = true; }
            if (b.y < b.r) { b.y = b.r; b.py = b.y + vy * bounce; }
            if (b.x < b.r) { b.x = b.r; b.px = b.x + vx * bounce; }
            if (b.x > W - b.r) { b.x = W - b.r; b.px = b.x + vx * bounce; }
          });
        }
        bodies.forEach(function (b) {
          if (!b.free || b === held) return;
          // Letters touching something roll; in the air they keep spinning.
          if (b.ground) b.va = b.va * 0.6 + ((b.x - b.px) / b.r) * 0.4;
          else b.va *= 0.997;
        });
      }

      // ---- pointer dragging ----
      var lastReal = -1e9;
      function local(e) {
        var r = el.getBoundingClientRect();
        return { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
      }
      el.addEventListener('pointerdown', function (e) {
        var t = e.target.closest && e.target.closest('.fll-ch');
        if (!t || mode !== 'pile') return;
        var b = bodies.filter(function (b) { return b.el === t; })[0];
        if (!b) return;
        e.preventDefault();
        lastReal = performance.now();
        stopFake();
        if (!b.free) release(b);
        held = b; b.el.classList.add('fll-held');
        var p = local(e); holdX = p.x; holdY = p.y;
        try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
      });
      el.addEventListener('pointermove', function (e) {
        if (!held || fake) return;
        lastReal = performance.now();
        var p = local(e); holdX = p.x; holdY = p.y;
      });
      function drop() {
        if (!held || fake) return;
        lastReal = performance.now();
        held.el.classList.remove('fll-held');
        held.va = (held.x - held.px) / held.r * 0.5;
        held = null;
        settleAt = performance.now();
      }
      el.addEventListener('pointerup', drop);
      el.addEventListener('pointercancel', drop);

      // ---- autoplay: a pretend hand picks a letter up and tosses it ----
      var fake = null;
      function stopFake() {
        if (!fake) return;
        fake.pause();
        if (held) held.el.classList.remove('fll-held');
        held = null; fake = null;
      }
      function toss() {
        var free = bodies.filter(function (b) { return b.free; });
        if (!free.length) return;
        var b = free[Math.floor(Math.random() * free.length)];
        held = b;
        var pos = { x: b.x, y: b.y };
        holdX = pos.x; holdY = pos.y;
        var toX = W * anime.utils.random(0.25, 0.75, 2), toY = H * anime.utils.random(0.2, 0.4, 2);
        fake = anime.animate(pos, {
          x: [{ to: (b.x + toX) / 2, duration: 450, ease: 'inOut(2)' }, { to: toX, duration: 350, ease: 'in(2)' }],
          y: [{ to: toY, duration: 450, ease: 'out(2)' }, { to: toY - fs * 0.6, duration: 350, ease: 'in(2)' }],
          onUpdate: function () { holdX = pos.x; holdY = pos.y; },
          onComplete: function () { b.va = (b.x - b.px) / b.r * 0.5; held = null; fake = null; }
        });
      }

      // ---- sequence: fall, pile, (go home, fall again) ----
      var mode = 'idle', modeAt = 0, settleAt = 0, nextToss = 0;
      function release(b) {
        b.free = true; b.going = false;
        b.px = b.x - anime.utils.random(-0.03, 0.03, 3) * fs;
        b.py = b.y + fs * 0.01;
        b.va = anime.utils.random(-0.05, 0.05, 3);
      }
      function fall() {
        mode = 'pile'; modeAt = performance.now(); settleAt = modeAt; nextToss = modeAt + 2200;
        var order = bodies.slice().sort(function () { return Math.random() - 0.5; });
        order.forEach(function (b, i) {
          b.timer = setTimeout(function () {
            if (b.free) return;
            // A little wobble as it breaks loose, then it drops.
            anime.animate(b, {
              a: [{ to: 0.18, duration: 70 }, { to: -0.14, duration: 90 }, { to: 0, duration: 70 }],
              onUpdate: render,
              onComplete: function () { if (mode === 'pile') release(b); }
            });
          }, i * 90 + Math.random() * 60);
        });
      }
      function goHome() {
        mode = 'home'; stopFake();
        bodies.forEach(function (b, i) {
          clearTimeout(b.timer);
          b.free = false; b.going = true;
          var turns = Math.round(b.a / (Math.PI * 2)) * Math.PI * 2;
          anime.animate(b, {
            x: b.hx, y: b.hy, a: turns, delay: i * 45, duration: 900, ease: 'inOut(3)',
            onComplete: function () { b.going = false; b.a = 0; }
          });
        });
        setTimeout(function () { if (mode === 'home') { mode = 'idle'; setTimeout(fall, 1000); } }, bodies.length * 45 + 950);
      }

      var running = false, raf = 0, last = 0, acc = 0;
      function frame(now) {
        if (!running) return;
        var dt = Math.min(0.05, (now - last) / 1000); last = now;
        acc += dt;
        while (acc >= DT) { step(); acc -= DT; }
        if (mode === 'pile') {
          if (autoplay && !held && now > nextToss && now - lastReal > 5000) {
            toss(); nextToss = now + 2600;
          }
          if (loop && !held && now - settleAt > hold + 1800 && now - lastReal > hold && now - modeAt > hold + bodies.length * 90 + 1500) goHome();
        }
        render();
        raf = requestAnimationFrame(frame);
      }

      var started = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!running) { running = true; last = performance.now(); acc = 0; raf = requestAnimationFrame(frame); }
          if (!started) { started = true; setTimeout(fall, delay); }
        } else {
          running = false; cancelAnimationFrame(raf);
        }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-falling-letters]').forEach(function (el) {
        if (el._fll) return;
        el._fll = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-falling-letters style="height: 320px">Gravity wins</div>

<!-- looping, with the odd letter tossed by itself: -->
<div data-falling-letters data-loop data-autoplay style="height: 320px">Gravity wins</div>