Domino Letters
The first letter rocks, tips over and knocks into the next, and the whole line goes down like a row of dominoes, each letter coming to rest on its neighbour while the last one falls flat. Then they stand back up, last letter first.
Knock-on effect
How to use it
- Paste the snippet just before
</head>. - Add
data-domino-lettersto any heading or text element. Leave a little room to the right for the last letter to fall into.
Options
Add any of these attributes to the same element.
data-loop | Keep toppling and standing back up. |
data-hold | How long the row stays down, and up, between moves. Default 1400 ms. |
data-speed | Speed multiplier: 2 is twice as fast. Default 1. |
data-spacing | Extra gap between letters, in em. Wider gaps make them lean further. Default 0.06. |
data-trigger | view (default) starts when scrolled into view. click knocks them over on a click or tap. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Domino Letters: the first letter tips over and knocks into the next, and
// the whole line topples like a row of dominoes, each letter coming to rest
// leaning on its neighbour. Then they stand back up, last letter first.
// Usage: <h1 data-domino-letters>Knock-on effect</h1>
// Optional: data-loop (keep toppling and standing up), data-hold (ms the
// toppled row stays down, and the upright row before toppling again when
// looping; default 1400), data-speed (multiplier; default 1),
// data-spacing (extra gap between letters, in em; default 0.06),
// data-trigger ("view", the default, or "click" to knock them over on a
// click or tap).
var DEFAULTS = { hold: 1400, speed: 1, spacing: 0.06 };
var css =
'.dmn-word{display:inline-block;white-space:nowrap}' +
'.dmn-ch{display:inline-block;line-height:1;transform-origin:100% 100%;will-change:transform}';
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, spacing) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var chars = [];
text.split(' ').forEach(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var word = document.createElement('span');
word.className = 'dmn-word';
word.setAttribute('aria-hidden', 'true');
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.className = 'dmn-ch';
s.textContent = c;
s.style.marginRight = spacing + 'em';
word.appendChild(s);
chars.push({ el: s });
});
el.appendChild(word);
});
return chars;
}
// Top-right corner of a letter that pivots on its bottom-right corner,
// tilted by angle a (radians, clockwise), in coordinates with y pointing up.
function corner(c, a, sx, sy) { return { x: c.right + sx * Math.cos(a) + sy * Math.sin(a), y: -sx * Math.sin(a) + sy * Math.cos(a) }; }
// Smallest tilt at which letter c's top touches the back (left side) of
// letter n, itself tilted by angle an. 90 degrees when it never touches.
function contact(c, n, an) {
if (!n || n.top !== c.top) return Math.PI / 2;
var bx = n.right - n.w * Math.cos(an), by = n.w * Math.sin(an); // n's bottom-left
var tx = bx + n.h * Math.sin(an), ty = by + n.h * Math.cos(an); // n's top-left
for (var deg = 0; deg <= 90; deg += 0.5) {
var a = deg * Math.PI / 180;
var p = corner(c, a, 0, c.h);
// Which side of n's back face is the corner on?
var cross = (tx - bx) * (p.y - by) - (ty - by) * (p.x - bx);
if (cross < 0 && p.y >= by - 1) return a;
}
return Math.PI / 2;
}
function setup(el) {
var d = el.dataset;
var hold = +d.hold || DEFAULTS.hold;
var speed = +d.speed || DEFAULTS.speed;
var spacing = d.spacing !== undefined ? +d.spacing : DEFAULTS.spacing;
var loop = d.loop !== undefined && d.loop !== 'false';
var trigger = d.trigger || 'view';
var chars = split(el, spacing);
if (reduced() || !chars.length) return;
function measure() {
chars.forEach(function (c) {
c.w = c.el.offsetWidth;
c.h = c.el.offsetHeight * 0.82; // roughly the height of the ink
c.right = c.el.offsetLeft + c.w;
c.top = c.el.offsetTop;
});
// Resting angles, solved from the last letter back to the first.
var n = chars.length;
for (var i = n - 1; i >= 0; i--) {
var c = chars[i], nx = chars[i + 1];
c.touch = contact(c, nx, 0); // where it first hits the upright neighbour
c.rest = nx ? contact(c, nx, nx.rest) : Math.PI / 2;
if (c.rest < c.touch) c.rest = c.touch;
}
}
measure();
var busy = false;
function topple(done) {
busy = true;
measure();
var fall = 300 / speed, t = 0, tl = anime.createTimeline({ onComplete: done });
chars.forEach(function (c, i) {
var deg = c.touch * 180 / Math.PI, restDeg = c.rest * 180 / Math.PI;
// How long this letter swings before hitting the next one.
var f = fall * (0.55 + 0.45 * Math.sqrt(c.touch / (Math.PI / 2)));
if (i === 0) tl.add(c.el, { rotate: [{ to: -4, duration: 260 / speed, ease: 'out(2)' }, { to: 0, duration: 160 / speed, ease: 'in(2)' }] }, 0);
var start = t + (i === 0 ? 420 / speed : 0);
var keys = [{ to: deg, duration: f, ease: 'in(2)' }];
if (!chars[i + 1] || chars[i + 1].top !== c.top) {
// The end of a line falls flat and bounces a little.
keys = [{ to: 90, duration: f * 1.2, ease: 'in(2)' }, { to: 82, duration: 110 / speed, ease: 'out(2)' }, { to: 90, duration: 140 / speed, ease: 'in(2)' }];
} else if (restDeg > deg + 0.5) {
keys.push({ to: restDeg, duration: fall * 1.4, ease: 'inOut(2)' });
}
tl.add(c.el, { rotate: keys }, start);
t = start + f;
});
}
function standUp(done) {
var tl = anime.createTimeline({ onComplete: function () { busy = false; if (done) done(); } });
for (var i = chars.length - 1, k = 0; i >= 0; i--, k++) {
tl.add(chars[i].el, { rotate: 0, duration: 520 / speed, ease: 'outBack(2.2)' }, k * 55 / speed);
}
}
function cycle() {
topple(function () {
setTimeout(function () {
standUp(function () { if (loop && visible) setTimeout(cycle, hold); else waiting = loop; });
}, hold);
});
}
var visible = false, waiting = false, started = false;
if (trigger === 'click') {
el.style.cursor = 'pointer';
el.addEventListener('click', function () { if (!busy) topple(function () { setTimeout(function () { standUp(); }, hold); }); });
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (!visible || trigger === 'click') return;
if (!started) { started = true; setTimeout(cycle, 300); }
else if (waiting && !busy) { waiting = false; setTimeout(cycle, 300); }
}, { threshold: 0.3 }).observe(el);
new ResizeObserver(function () { if (!busy) measure(); }).observe(el);
}
function init() {
document.querySelectorAll('[data-domino-letters]').forEach(function (el) {
if (el._dmn) return;
el._dmn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-domino-letters data-loop>Knock-on effect</h1>
<!-- knock them over with a click: -->
<h1 data-domino-letters data-trigger="click">Push me</h1>