← Text effects

Letter Sort

Each word starts as a jumble. Two letters at a time hop over and under each other to swap places, and each one flashes as it lands in the right spot, until the word spells itself out. Loop it and they jumble up again.

Sorting it out

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-letter-sort to a heading. Letters stay inside their own word, so each word sorts itself.

Options

Add any of these attributes to the same element.

data-loopJumble up again and re-sort, forever.
data-holdHow long the sorted text stays before jumbling, when looping. Default 1800 ms.
data-durationHow long each swap takes. Default 520 ms.
data-colorColour a letter flashes when it lands in place. Default #a855f7.

Long words take longer to sort, since there are more swaps to make: about half a second each.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Letter Sort: the letters of each word start jumbled, then swap places two
    // at a time, hopping over and under each other, until the word is spelled
    // right. Each letter flashes as it lands in its proper spot.
    // Usage: <h1 data-letter-sort>Sorted</h1>
    // Optional: data-loop (jumble again and re-sort, forever), data-hold (ms the
    // sorted word stays before jumbling when looping; default 1800),
    // data-duration (ms for one swap; default 520), data-color (flash colour
    // for a letter that lands in place; default #a855f7).
    var DEFAULTS = { hold: 1800, duration: 520, color: '#a855f7' };

    var css =
      '.lsrt-word{display:inline-block;white-space:nowrap}' +
      '.lsrt-ch{display:inline-block;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) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var words = [];
      text.split(' ').forEach(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'lsrt-word';
        word.setAttribute('aria-hidden', 'true');
        var letters = Array.from(w).map(function (c, k) {
          var s = document.createElement('span');
          s.className = 'lsrt-ch';
          s.textContent = c;
          word.appendChild(s);
          return { el: s, ch: c, home: k };
        });
        el.appendChild(word);
        // order[k] = the letter currently sitting in slot k.
        words.push({ letters: letters, order: letters.slice() });
      });
      return words;
    }

    // A jumble that isn't already (or nearly) spelled right.
    function shuffle(word) {
      var o = word.letters.slice(), n = o.length;
      if (n < 2) return o;
      for (var tries = 0; tries < 20; tries++) {
        for (var i = n - 1; i > 0; i--) {
          var j = Math.floor(Math.random() * (i + 1)), tmp = o[i]; o[i] = o[j]; o[j] = tmp;
        }
        var right = o.filter(function (l, k) { return l.ch === word.letters[k].ch; }).length;
        if (right <= Math.floor(n / 3)) break;
      }
      return o;
    }

    function setup(el) {
      var d = el.dataset;
      var hold = +d.hold || DEFAULTS.hold;
      var duration = +d.duration || DEFAULTS.duration;
      var color = d.color || DEFAULTS.color;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var words = split(el);
      if (reduced()) return;

      function measure() {
        words.forEach(function (w) {
          w.letters.forEach(function (l) { l.w = l.el.offsetWidth; l.x0 = l.el.offsetLeft; });
        });
      }
      // Where each letter should sit for the word's current order.
      function targets(w) {
        var x = w.letters.length ? w.letters[0].x0 : 0;
        w.order.forEach(function (l) { l.tx = x - l.x0; x += l.w; });
      }
      function place(w) {
        targets(w);
        w.letters.forEach(function (l) { anime.utils.set(l.el, { x: l.tx }); });
      }
      measure();

      // One swap in word w between slots i and j; all letters slide to their
      // new spots, the two swapped ones hop past each other.
      function swap(w, i, j, done) {
        var a = w.order[i], b = w.order[j];
        w.order[i] = b; w.order[j] = a;
        targets(w);
        var em = parseFloat(getComputedStyle(el).fontSize);
        var lift = em * Math.min(0.75, 0.3 + 0.1 * (j - i));
        w.letters.forEach(function (l) {
          var p = { x: l.tx, duration: duration, ease: 'inOut(3)' };
          if (l === a || l === b) {
            var up = l === a ? -lift : lift * 0.8;
            p.y = [{ to: up, duration: duration / 2, ease: 'out(2)' }, { to: 0, duration: duration / 2, ease: 'in(2)' }];
            p.scale = [{ to: 1.12, duration: duration / 2 }, { to: 1, duration: duration / 2 }];
          }
          anime.animate(l.el, p);
        });
        setTimeout(function () {
          // Flash whichever letter just landed in the right place.
          [i, j].forEach(function (k) {
            if (w.order[k].ch === w.letters[k].ch) {
              var t = w.order[k].el;
              anime.animate(t, { color: [color, getComputedStyle(el).color], duration: 700, ease: 'out(2)', onComplete: function () { t.style.color = ''; } });
            }
          });
          done();
        }, duration + 60);
      }

      // Selection sort, left to right, through every word in turn.
      function sortAll(done) {
        var wi = 0, k = 0;
        (function next() {
          if (!alive) { pending = next; return; }
          while (wi < words.length) {
            var w = words[wi];
            if (k >= w.order.length) { wi++; k = 0; continue; }
            var want = w.letters[k].ch;
            if (w.order[k].ch === want) { k++; continue; }
            var j = k + 1;
            while (j < w.order.length && w.order[j].ch !== want) j++;
            var slot = k;
            k++;
            swap(w, slot, j, function () { setTimeout(next, 90); });
            return;
          }
          done();
        })();
      }

      function jumble() {
        words.forEach(function (w) {
          w.order = shuffle(w);
          targets(w);
          w.letters.forEach(function (l, i) {
            anime.animate(l.el, {
              x: l.tx,
              y: [{ to: -parseFloat(getComputedStyle(el).fontSize) * anime.utils.random(0.2, 0.5, 2), duration: 260, ease: 'out(2)' }, { to: 0, duration: 340, ease: 'outBounce' }],
              rotate: [{ to: anime.utils.random(-25, 25), duration: 260 }, { to: 0, duration: 340 }],
              delay: i * 25, duration: 600, ease: 'inOut(2)'
            });
          });
        });
      }

      var alive = false, pending = null, started = false;
      function cycle() {
        sortAll(function () {
          if (!loop) return;
          setTimeout(function () {
            jumble();
            setTimeout(cycle, 1100);
          }, hold);
        });
      }

      // Start jumbled, before it is seen.
      words.forEach(function (w) { w.order = shuffle(w); place(w); });

      new IntersectionObserver(function (entries) {
        alive = entries[0].isIntersecting;
        if (!alive) return;
        if (!started) { started = true; setTimeout(cycle, 700); }
        else if (pending) { var p = pending; pending = null; p(); }
      }, { threshold: 0.2 }).observe(el);

      new ResizeObserver(function () {
        words.forEach(function (w) { w.letters.forEach(function (l) { anime.utils.set(l.el, { x: 0 }); }); });
        measure();
        words.forEach(place);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-letter-sort]').forEach(function (el) {
        if (el._lsrt) return;
        el._lsrt = true;
        setup(el);
      });
    }

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

<!-- quicker swaps, on a loop: -->
<h1 data-letter-sort data-loop data-duration="360">Sorting it out</h1>