← Text reveals

Letter Reels

Each letter spins through the alphabet like a fruit-machine reel and clunks into place, the reels stopping one by one from left to right. Digits spin through 0–9.

JACKPOT 777

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-letter-reels to a heading or short line of text.

Options

Add any of these attributes to the same element.

data-loopSpin again after a pause.
data-holdHow long the text stays before spinning again, when looping. Default 2000 ms.
data-spinsHow many symbols the first reel spins past. Later reels go a few further. Default 12.
data-charsThe symbols to spin through, e.g. ★♦♣7. Default the alphabet, in the letter’s own case (0–9 for digits).
data-durationHow long the first reel spins. Default 1200 ms.
data-staggerHow much longer each next reel spins. Default 160 ms.
data-delayWait before the reels start. Default 0 ms.

Spaces and punctuation stay still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Letter Reels: each letter spins through the alphabet like a fruit-machine
    // reel and clunks into place, left to right.
    // Usage: <h1 data-letter-reels>JACKPOT</h1>
    // Optional: data-loop (spin again after a pause), data-hold (ms to stay
    // before spinning again), data-spins (how many symbols the first reel goes
    // past; default 12, later reels go a few further), data-chars (the symbols
    // to spin through; default the alphabet in the letter's case, or 0-9 for
    // digits), data-duration (ms the first reel spins), data-stagger (ms each
    // later reel keeps spinning after the one before), data-delay (ms before
    // starting).
    // Spaces and punctuation stay still.
    var DEFAULTS = { spins: 12, duration: 1200, stagger: 160, delay: 0, hold: 2000 };
    var UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    var LOWER = 'abcdefghijklmnopqrstuvwxyz';
    var DIGITS = '0123456789';

    var css =
      '.lrl-word{display:inline-block;white-space:nowrap}' +
      // The hidden copy of the letter gives the window its size and baseline;
      // the reel slides past behind a clip that only trims top and bottom.
      '.lrl-win{display:inline-block;position:relative;clip-path:inset(-0.04em -0.5em)}' +
      '.lrl-ghost{visibility:hidden}' +
      '.lrl-reel{position:absolute;left:-0.5em;right:-0.5em;top:0;text-align:center;will-change:transform}' +
      '.lrl-cell{display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function poolFor(c, custom) {
      if (custom) return custom;
      if (/[A-Z]/.test(c)) return UPPER;
      if (/[a-z]/.test(c)) return LOWER;
      if (/[0-9]/.test(c)) return DIGITS;
      return '';
    }

    function split(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var custom = el.dataset.chars || '';
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var reels = [];
      text.split(' ').forEach(function (word, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'lrl-word';
        w.setAttribute('aria-hidden', 'true');
        Array.from(word).forEach(function (c) {
          var pool = poolFor(c, custom);
          if (!pool) { w.appendChild(document.createTextNode(c)); return; }
          var win = document.createElement('span');
          win.className = 'lrl-win';
          var ghost = document.createElement('span');
          ghost.className = 'lrl-ghost';
          ghost.textContent = c;
          var reel = document.createElement('span');
          reel.className = 'lrl-reel';
          win.appendChild(ghost);
          win.appendChild(reel);
          w.appendChild(win);
          reels.push({ el: reel, char: c, pool: pool });
        });
        el.appendChild(w);
      });
      return reels;
    }

    // Fill a reel: the symbol showing now, a run of random ones, the real
    // letter, and one spare below it to show when the reel overshoots.
    function fill(r, first, count) {
      var cells = [first];
      for (var i = 0; i < count; i++) cells.push(r.pool.charAt(Math.floor(Math.random() * r.pool.length)));
      cells.push(r.char);
      cells.push(r.pool.charAt(Math.floor(Math.random() * r.pool.length)));
      r.el.textContent = '';
      cells.forEach(function (c) {
        var s = document.createElement('span');
        s.className = 'lrl-cell';
        s.textContent = c;
        r.el.appendChild(s);
      });
      r.cells = cells.length;
      r.stop = cells.length - 2;
      anime.utils.set(r.el, { y: '0%' });
    }

    function randomChar(r) {
      return r.pool.charAt(Math.floor(Math.random() * r.pool.length));
    }

    function spin(el) {
      var st = el._lrl;
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var spins = +d.spins || DEFAULTS.spins;

      var tl = anime.createTimeline({
        delay: st.played ? +d.hold || DEFAULTS.hold : +d.delay || DEFAULTS.delay,
        onComplete: function () {
          if (!loop) return;
          // Start the next spin from the letter that's showing, so the swap is
          // invisible.
          st.reels.forEach(function (r, i) { fill(r, r.char, spins + i * 3); });
          spin(el);
        }
      });
      st.played = true;
      st.reels.forEach(function (r, i) {
        var cell = 100 / r.cells;
        tl.add(r.el, {
          y: [
            // Spin down past the letter by a fraction of a symbol...
            { to: -(r.stop + 0.22) * cell + '%', duration: duration + i * stagger, ease: 'out(2.4)' },
            // ...and clunk back into place.
            { to: -r.stop * cell + '%', duration: 260, ease: 'outBack(2)' }
          ]
        }, 0);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          spin(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-letter-reels]').forEach(function (el) {
        if (el._lrl) return;
        var spins = +el.dataset.spins || DEFAULTS.spins;
        var st = (el._lrl = { reels: split(el) });
        if (reduced()) {
          st.reels.forEach(function (r) { r.el.textContent = r.char; });
          return;
        }
        st.reels.forEach(function (r, i) { fill(r, randomChar(r), spins + i * 3); });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-letter-reels>JACKPOT</h1>

<!-- or, looping: -->
<h1 data-letter-reels data-loop>JACKPOT 777</h1>