← Text reveals

Echo Settle

The text slides in trailed by a string of fading, ghosted copies of itself. The echoes lag a little further behind each one, then catch up and merge into the one solid word.

Echo settle

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-echo-settle to any heading or text element.

Options

Add any of these attributes to the same element.

data-colorThe colour of the echoes. Default #a855f7.
data-echoesHow many ghosted copies trail behind. Default 4.
data-fromWhere it slides in from: left (default), right, top or bottom.
data-distanceHow far it travels, in em. Default 1.2.
data-durationTime to settle. Default 1100 ms.
data-loopSettle, hold, slide out the other side and in again.
data-holdHow long the text stays before leaving, when looping. Default 1800 ms.
data-delayWait before starting. Default 0 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Echo Settle: the text slides in trailed by fading, ghosted copies of
    // itself, which catch up and merge into the one solid word.
    // Usage: <h1 data-echo-settle>Echo</h1>
    // Optional: data-color (the ghosts' colour, default "#a855f7"), data-echoes
    // (how many ghosts, default 4), data-from (left, right, top or bottom,
    // default left), data-distance (how far it travels, in em, default 1.2),
    // data-duration (ms, default 1100), data-loop (slide out and in again),
    // data-hold (ms to stay before leaving, when looping), data-delay (ms).
    var DEFAULTS = { color: '#a855f7', echoes: 4, from: 'left', distance: 1.2, duration: 1100, hold: 1800, delay: 0 };

    var css =
      '.ecs-on{position:relative;isolation:isolate}' +
      '.ecs-main{display:block;position:relative;z-index:1}' +
      '.ecs-ghost{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;filter:blur(.4px)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset, cs = getComputedStyle(el);
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var n = Math.max(1, Math.min(8, +d.echoes || DEFAULTS.echoes));
      el.setAttribute('aria-label', text);
      el.textContent = '';
      el.classList.add('ecs-on');
      var ghosts = [];
      for (var i = 0; i < n; i++) {
        var g = document.createElement('span');
        g.className = 'ecs-ghost';
        g.setAttribute('aria-hidden', 'true');
        g.textContent = text;
        g.style.color = d.color || DEFAULTS.color;
        g.style.padding = cs.padding;
        el.appendChild(g);
        ghosts.push(g);
      }
      var main = document.createElement('span');
      main.className = 'ecs-main';
      main.setAttribute('aria-hidden', 'true');
      main.textContent = text;
      el.appendChild(main);
      return { main: main, ghosts: ghosts };
    }

    // The offset (in px) for a travelled fraction `k` of the distance.
    function axis(el) {
      var from = el.dataset.from || DEFAULTS.from;
      var dist = (+el.dataset.distance || DEFAULTS.distance) * parseFloat(getComputedStyle(el).fontSize);
      var sign = from === 'right' || from === 'bottom' ? 1 : -1;
      return { prop: from === 'top' || from === 'bottom' ? 'y' : 'x', dist: sign * dist };
    }

    function tween(target, prop, from, to, extra) {
      var o = {};
      o[prop] = [from, to];
      for (var k in extra) o[k] = extra[k];
      return o;
    }

    // dir 1 = arrive, -1 = leave (towards the other side).
    function move(el, dir, done) {
      var s = el._ecs, d = el.dataset, a = axis(el);
      var duration = +d.duration || DEFAULTS.duration;
      var tl = anime.createTimeline({ onComplete: done });
      var n = s.ghosts.length;
      var start = dir > 0 ? a.dist : 0, end = dir > 0 ? 0 : -a.dist;
      tl.add(s.main, tween(s.main, a.prop, start, end, {
        opacity: dir > 0 ? [0, 1] : [1, 0],
        duration: dir > 0 ? duration : duration * 0.7,
        ease: dir > 0 ? 'outExpo' : 'inQuart'
      }), 0);
      s.ghosts.forEach(function (g, i) {
        var lag = (i + 1) / n;
        var dur = (dir > 0 ? duration : duration * 0.7) * (1 + lag * 0.6);
        var peak = 0.55 * (1 - i / (n + 1));
        tl.add(g, tween(g, a.prop, start, end, {
          duration: dur,
          delay: lag * 90,
          ease: dir > 0 ? 'outExpo' : 'inQuart'
        }), 0);
        tl.add(g, { opacity: [0, peak], duration: 180, ease: 'outQuad' }, lag * 90);
        tl.add(g, { opacity: [peak, 0], duration: dur * 0.6, ease: 'inQuad' }, lag * 90 + dur * 0.4);
      });
    }

    function play(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      move(el, 1, function () {
        if (!loop) return;
        setTimeout(function () {
          move(el, -1, function () { setTimeout(function () { play(el); }, 300); });
        }, +d.hold || DEFAULTS.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          var el = e.target;
          setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-echo-settle]').forEach(function (el) {
        if (el._ecs) return;
        el._ecs = setup(el);
        if (reduced()) return;
        el._ecs.main.style.opacity = 0;
        io.observe(el);
      });
    }

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

<!-- or, dropping in from above with pink echoes: -->
<h1 data-echo-settle data-from="top" data-color="#ec4899">Echo settle</h1>