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
- Paste the snippet just before
</head>. - Add
data-falling-lettersto 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-delay | Wait before the first letter falls. Default 600 ms. |
data-gravity | How hard they fall: 0.5 is floaty, 2 heavy. Default 1. |
data-bounce | How much they bounce off the walls and floor, from 0 to 1. Default 0.3. |
data-loop | After a while the letters fly back into place and fall again. |
data-hold | How long the pile stays before going home when looping. Default 4000 ms. |
data-autoplay | Now 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.
<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><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>