← Text reveals

Line-led Reveal

A line draws itself across, then the words rise up from behind it one after another, as if the line were the lip of a slot they climb out of.

Line it up

How to use it

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

Options

Add any of these attributes to the same element.

data-colorThe line. Default #a855f7.
data-thicknessThe line’s thickness, in em (never thinner than 2px). Default 0.06.
data-keepfalse to draw the line away once the words are up.
data-durationHow long each word takes to rise. Default 900 ms.
data-staggerGap between words. Default 80 ms.
data-loopDraw, rise, hold, sink back and draw again.
data-holdHow long the words stay up, 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 () {
    // Line-led Reveal: a line draws across under the text, then the words rise
    // up from behind it, as if the line were the edge of a slot.
    // Usage: <h1 data-line-led-reveal>Line it up</h1>
    // Optional: data-color (the line, default "#a855f7"), data-thickness (in em,
    // default 0.06), data-keep ("false" to draw the line away once the text is
    // up), data-duration (ms for the words to rise, default 900), data-stagger
    // (ms between words, default 80), data-loop (words sink, line undraws, and
    // again), data-hold (ms to stay, when looping), data-delay (ms).
    var DEFAULTS = { color: '#a855f7', thickness: 0.06, duration: 900, stagger: 80, hold: 1800, delay: 0 };

    var css =
      '.llr-box{display:inline-block;position:relative;padding-bottom:.1em;max-width:100%}' +
      '.llr-clip{display:block;padding-bottom:.16em;clip-path:inset(-1em -.5em 0 -.5em)}' +
      '.llr-w{display:inline-block;white-space:nowrap;will-change:transform}' +
      '.llr-line{position:absolute;left:0;right:0;bottom:0;border-radius:99px;transform-origin:0 50%}';
    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;
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var box = document.createElement('span');
      box.className = 'llr-box';
      box.setAttribute('aria-hidden', 'true');
      var clip = document.createElement('span');
      clip.className = 'llr-clip';
      var words = text.split(' ').map(function (word, i) {
        if (i) clip.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'llr-w';
        w.textContent = word;
        clip.appendChild(w);
        return w;
      });
      var line = document.createElement('span');
      line.className = 'llr-line';
      line.style.background = d.color || DEFAULTS.color;
      line.style.height = 'max(2px, ' + (+d.thickness || DEFAULTS.thickness) + 'em)';
      box.appendChild(clip);
      box.appendChild(line);
      el.appendChild(box);
      return { clip: clip, words: words, line: line };
    }

    // How far each word must drop to sit just below the line.
    function depth(s, w) {
      return s.clip.offsetHeight - w.offsetTop + 4;
    }

    function play(el) {
      var s = el._llr, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var keep = d.keep !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var tl = anime.createTimeline({
        onComplete: function () { if (loop) setTimeout(function () { play(el); }, 400); }
      });
      s.line.style.transformOrigin = '0 50%';
      tl.add(s.line, { scaleX: [0, 1], duration: 650, ease: 'inOutQuart' }, 0);
      var t = 530, n = s.words.length;
      s.words.forEach(function (w, i) {
        tl.add(w, { y: [depth(s, w), 0], duration: duration, ease: 'outQuart' }, t + i * stagger);
      });
      var up = t + (n - 1) * stagger + duration;
      var unline = { scaleX: [1, 0], duration: 500, ease: 'inOutQuart', onBegin: function () { s.line.style.transformOrigin = '100% 50%'; } };
      if (!keep) tl.add(s.line, unline, up - 200);
      if (loop) {
        var out = up + (+d.hold || DEFAULTS.hold);
        s.words.forEach(function (w, i) {
          tl.add(w, { y: [0, depth(s, w)], duration: duration * 0.55, ease: 'inQuart' }, out + i * stagger * 0.5);
        });
        if (keep) tl.add(s.line, unline, out + (n - 1) * stagger * 0.5 + duration * 0.4);
      }
    }

    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-line-led-reveal]').forEach(function (el) {
        if (el._llr) return;
        var s = el._llr = setup(el);
        if (reduced()) {
          if (el.dataset.keep === 'false') s.line.style.transform = 'scaleX(0)';
          return;
        }
        s.line.style.transform = 'scaleX(0)';
        s.words.forEach(function (w) { w.style.transform = 'translateY(' + depth(s, w) + 'px)'; });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-line-led-reveal>Line it up</h1>

<!-- or, with a thin dark line that leaves afterwards: -->
<h1 data-line-led-reveal data-color="#111" data-thickness="0.03" data-keep="false">Line it up</h1>