← Text reveals

Line Reveal

A paragraph arrives one line at a time, each line rising from behind its own mask. The lines are worked out from where the browser really wraps the text, and again whenever the width changes.

Good motion guides the eye. Each line arrives in turn, so the reader takes in one thought at a time, in the order you wrote it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-line-reveal to a paragraph or heading that wraps onto more than one line.

Options

Add any of these attributes to the same element.

data-loopRise in, hold, rise out and in again.
data-holdHow long the text stays before leaving, when looping. Default 2400 ms.
data-durationHow long each line takes to rise. Default 1000 ms.
data-staggerGap between lines. Default 120 ms.
data-delayWait before the first line. Default 0 ms.

Works on plain text: links or bold inside the element are flattened to text when it’s split into lines.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Line Reveal: a paragraph arrives one line at a time, each line rising from
    // behind its own mask. Lines are worked out from where the browser actually
    // wraps the text, and again whenever the width changes.
    // Usage: <p data-line-reveal>A paragraph that wraps onto a few lines…</p>
    // Optional: data-loop (lines rise out and in again), data-hold (ms to stay
    // before leaving), data-duration (ms per line), data-stagger (ms between
    // lines), data-delay (ms before starting).
    var DEFAULTS = { duration: 1000, stagger: 120, delay: 0, hold: 2400 };

    var css =
      '.lrv-line{display:block;clip-path:inset(-0.05em -0.3em -0.15em -0.3em)}' +
      '.lrv-inner{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;
    }

    // Lay the words out, see where the browser breaks them, then rebuild the
    // text as one masked block per line.
    function split(el, words) {
      el.textContent = '';
      var spans = words.map(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var s = document.createElement('span');
        s.textContent = w;
        el.appendChild(s);
        return s;
      });
      var rows = [];
      var top = null;
      spans.forEach(function (s, i) {
        if (top === null || s.offsetTop > top + 2) { rows.push([]); top = s.offsetTop; }
        rows[rows.length - 1].push(words[i]);
      });
      el.textContent = '';
      return rows.map(function (row) {
        var line = document.createElement('span');
        line.className = 'lrv-line';
        line.setAttribute('aria-hidden', 'true');
        var inner = document.createElement('span');
        inner.className = 'lrv-inner';
        inner.textContent = row.join(' ');
        line.appendChild(inner);
        el.appendChild(line);
        return inner;
      });
    }

    function play(el) {
      var st = el._lrv;
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = +d.stagger || DEFAULTS.stagger;

      var tl = anime.createTimeline({ loop: loop, loopDelay: 300, delay: st.played ? 0 : +d.delay || DEFAULTS.delay });
      tl.add(st.lines, {
        y: ['110%', '0%'],
        duration: duration,
        ease: 'outQuart',
        delay: anime.stagger(stagger)
      });
      if (loop) {
        tl.add(st.lines, {
          y: ['0%', '-110%'],
          duration: duration * 0.6,
          ease: 'inQuart',
          delay: anime.stagger(stagger * 0.6)
        }, '+=' + (+d.hold || DEFAULTS.hold));
      }
      st.tl = tl;
      st.played = true;
    }

    function hide(lines) {
      lines.forEach(function (l) { l.style.transform = 'translateY(110%)'; });
    }

    function init() {
      if (reduced()) return;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      var els = [];
      document.querySelectorAll('[data-line-reveal]').forEach(function (el) {
        if (el._lrv) return;
        var text = el.textContent.replace(/\s+/g, ' ').trim();
        el.setAttribute('aria-label', text);
        var st = (el._lrv = { words: text.split(' ') });
        st.lines = split(el, st.words);
        st.width = el.clientWidth;
        hide(st.lines);
        els.push(el);
        io.observe(el);
      });
      if (!els.length) return;

      function resplit(el) {
        var st = el._lrv;
        if (st.tl) st.tl.pause();
        st.lines = split(el, st.words);
        st.width = el.clientWidth;
        if (!st.played) hide(st.lines);
        else if (el.dataset.loop !== undefined && el.dataset.loop !== 'false') play(el);
      }

      // Web fonts can change where lines break, so split again once they load...
      if (document.fonts) document.fonts.ready.then(function () { els.forEach(resplit); });
      // ...and whenever the width changes.
      var timer;
      window.addEventListener('resize', function () {
        clearTimeout(timer);
        timer = setTimeout(function () {
          els.forEach(function (el) { if (el.clientWidth !== el._lrv.width) resplit(el); });
        }, 200);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-line-reveal>
  Good motion guides the eye. Each line arrives in turn,
  so the reader takes in one thought at a time.
</p>