← Text reveals

Stacked Headline Build

The lines of a headline arrive one at a time from below. Each new line bumps the ones above it up to make room, so the stack grows from the middle and stays centred.

Plan it
Build it
Ship it

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stacked-headline-build to a heading, with its lines separated by <br> (or each line in its own element).

Options

Add any of these attributes to the same element.

data-intervalGap between lines. Default 520 ms.
data-durationTime for each line to land. Default 700 ms.
data-loopClear the stack and build it again.
data-holdHow long the full stack stays, when looping. Default 1800 ms.
data-delayWait before starting. Default 0 ms.

Screen readers hear the whole headline at once.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stacked Headline Build: the lines of a headline arrive one by one from
    // below, and each new line nudges the ones already there up to make room,
    // so the stack stays centred as it grows.
    // Usage: <h1 data-stacked-headline-build>Plan it<br>Build it<br>Ship it</h1>
    // (separate lines with <br>, or put each line in its own child element).
    // Optional: data-interval (ms between lines, default 520), data-duration
    // (ms for each line to land, default 700), data-loop (clear the stack and
    // build it again), data-hold (ms to stay, when looping, default 1800),
    // data-delay (ms).
    var DEFAULTS = { interval: 520, duration: 700, hold: 1800, delay: 0 };

    var css =
      '.shlb{position:relative}' +
      '.shlb-line{display:block;will-change:transform,opacity}';
    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 parts;
      if (el.querySelector('br')) {
        parts = el.innerHTML.split(/<br\s*\/?>/i);
      } else if (el.children.length) {
        parts = Array.from(el.children).map(function (c) { return c.outerHTML; });
      } else {
        parts = [el.innerHTML];
      }
      parts = parts.map(function (p) { return p.trim(); }).filter(Boolean);
      var holder = document.createElement('div');
      var label = parts.map(function (p) { holder.innerHTML = p; return holder.textContent.trim(); }).join(' ');
      el.setAttribute('aria-label', label);
      el.classList.add('shlb');
      el.innerHTML = '';
      return parts.map(function (p) {
        var line = document.createElement('span');
        line.className = 'shlb-line';
        line.setAttribute('aria-hidden', 'true');
        line.innerHTML = p;
        el.appendChild(line);
        return line;
      });
    }

    // Where every line must sit, as an offset from its natural place, when the
    // first k lines are showing: the visible group centred in the full block.
    function offsets(el, lines, k) {
      var first = lines[0], last = lines[k - 1];
      var top = first.offsetTop, bottom = last.offsetTop + last.offsetHeight;
      return (el.clientHeight - top - bottom) / 2;
    }

    function play(el) {
      var lines = el._shlb, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var interval = +d.interval || DEFAULTS.interval;
      var duration = +d.duration || DEFAULTS.duration;
      var tl = anime.createTimeline({
        onComplete: function () { if (loop) setTimeout(function () { play(el); }, 300); }
      });
      var lh = lines[0].offsetHeight;
      lines.forEach(function (line) { anime.utils.set(line, { opacity: 0, y: 0, scale: 1 }); });
      lines.forEach(function (line, i) {
        var t = i * interval, off = offsets(el, lines, i + 1);
        // The new line rises in from just below where it will sit...
        tl.add(line, {
          opacity: [0, 1],
          y: [off + lh * 0.75, off],
          scale: [0.92, 1],
          duration: duration,
          ease: 'outBack(1.3)'
        }, t);
        // ...and shoves everything above it up by the same step.
        if (i) {
          tl.add(lines.slice(0, i), { y: off, duration: duration * 0.85, ease: 'outBack(1.6)' }, t + 40);
        }
      });
      var end = (lines.length - 1) * interval + duration;
      if (loop) {
        var out = end + (+d.hold || DEFAULTS.hold);
        tl.add(lines, {
          opacity: 0,
          y: '-=' + Math.round(lh * 0.5),
          duration: 420,
          delay: anime.stagger(60),
          ease: 'inQuad'
        }, out);
      }
    }

    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.3 });
      document.querySelectorAll('[data-stacked-headline-build]').forEach(function (el) {
        if (el._shlb) return;
        var lines = el._shlb = setup(el);
        if (reduced() || !lines.length) return;
        lines.forEach(function (line) { line.style.opacity = 0; });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-stacked-headline-build>Plan it<br>Build it<br>Ship it</h1>

<!-- or, a slower build that repeats: -->
<h1 data-stacked-headline-build data-interval="800" data-loop>Plan it<br>Build it<br>Ship it</h1>