← Text effects

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

  1. Paste the snippet just before </head>.
  2. Add data-domino-letters to 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-loopKeep toppling and standing back up.
data-holdHow long the row stays down, and up, between moves. Default 1400 ms.
data-speedSpeed multiplier: 2 is twice as fast. Default 1.
data-spacingExtra gap between letters, in em. Wider gaps make them lean further. Default 0.06.
data-triggerview (default) starts when scrolled into view. click knocks them over on a click or tap.
1 · Snippet
<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>
2 · Your HTML
<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>