← Text reveals

Text Unfold

Each line is creased across the middle and folded shut, then opens out flat like a sheet of paper, one line after another. The lines are worked out from where the browser really wraps the text, and again whenever the width changes.

Each line was folded shut along a crease, and now it opens out flat, like a letter taken from its envelope.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-text-unfold to a heading or paragraph.

Options

Add any of these attributes to the same element.

data-loopUnfold, hold, fold shut and unfold again.
data-holdHow long the text stays before folding, when looping. Default 2200 ms.
data-durationHow long each line takes to open. Default 1200 ms.
data-staggerGap between lines. Default 150 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 () {
    // Text Unfold: each line is creased across the middle and folded shut, and
    // opens out flat like a sheet of paper, one line after another. Lines are
    // worked out from where the browser actually wraps the text, and again
    // whenever the width changes.
    // Usage: <p data-text-unfold>A paragraph that wraps onto a few lines…</p>
    // Optional: data-loop (lines fold shut and open again), data-hold (ms to
    // stay before folding), data-duration (ms per line), data-stagger (ms
    // between lines), data-delay (ms before starting).
    var DEFAULTS = { duration: 1200, stagger: 150, delay: 0, hold: 2200 };
    var SHUT = 88;

    var css =
      '.tuf-line{display:block;position:relative;perspective:18em}' +
      // The hidden copy gives the line its size; the two halves sit over it.
      '.tuf-ghost,.tuf-text{display:block;white-space:nowrap;padding:0.12em 0}' +
      '.tuf-ghost{visibility:hidden}' +
      // Top half, hinged along its top edge, carries the bottom half, which is
      // hinged along the crease.
      '.tuf-top{position:absolute;left:0;right:0;top:0;height:50%;transform-origin:50% 0;' +
      'transform-style:preserve-3d;will-change:transform}' +
      '.tuf-face{position:absolute;left:0;right:0;top:0;height:100%;overflow:hidden}' +
      '.tuf-bottom{top:100%;transform-origin:50% 0;will-change:transform}' +
      '.tuf-text{position:absolute;left:0;right:0;top:0}' +
      '.tuf-bottom .tuf-text{top:-100%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function span(cls, text) {
      var s = document.createElement('span');
      s.className = cls;
      if (text) s.textContent = text;
      return s;
    }

    // Lay the words out, see where the browser breaks them, then rebuild the
    // text as one folded block per line.
    function split(el, words) {
      el.textContent = '';
      var spans = words.map(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var s = span('', 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 text = row.join(' ');
        var line = span('tuf-line');
        line.setAttribute('aria-hidden', 'true');
        var topHalf = span('tuf-top');
        var topFace = span('tuf-face');
        var bottomHalf = span('tuf-face tuf-bottom');
        topFace.appendChild(span('tuf-text', text));
        bottomHalf.appendChild(span('tuf-text', text));
        topHalf.appendChild(topFace);
        topHalf.appendChild(bottomHalf);
        line.appendChild(span('tuf-ghost', text));
        line.appendChild(topHalf);
        el.appendChild(line);
        return { line: line, top: topHalf, bottom: bottomHalf, angle: SHUT, opacity: 0 };
      });
    }

    // The top half tips back by the angle and the bottom half folds the other
    // way by twice that, so the two stay joined at the crease like a zigzag.
    function render(lines) {
      lines.forEach(function (l) {
        l.top.style.transform = 'rotateX(' + l.angle + 'deg)';
        l.bottom.style.transform = 'rotateX(' + (-2 * l.angle) + 'deg)';
        l.line.style.opacity = l.opacity;
      });
    }

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

      var tl = anime.createTimeline({
        loop: loop,
        loopDelay: 300,
        delay: st.played ? 0 : +d.delay || DEFAULTS.delay,
        onUpdate: function () { render(lines); }
      });
      tl.add(lines, {
        angle: { from: SHUT, to: 0, ease: 'out(3)' },
        opacity: { from: 0, to: 1, duration: duration * 0.35, ease: 'linear' },
        duration: duration,
        delay: anime.stagger(stagger)
      });
      if (loop) {
        tl.add(lines, {
          angle: SHUT,
          opacity: 0,
          duration: duration * 0.6,
          ease: 'in(3)',
          delay: anime.stagger(stagger * 0.6)
        }, '+=' + (+d.hold || DEFAULTS.hold));
      }
      st.tl = tl;
      st.played = true;
    }

    function settle(lines, open) {
      lines.forEach(function (l) {
        l.angle = open ? 0 : SHUT;
        l.opacity = open ? 1 : 0;
      });
      render(lines);
    }

    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-text-unfold]').forEach(function (el) {
        if (el._tuf) return;
        var text = el.textContent.replace(/\s+/g, ' ').trim();
        el.setAttribute('aria-label', text);
        var st = (el._tuf = { words: text.split(' ') });
        st.lines = split(el, st.words);
        st.width = el.clientWidth;
        settle(st.lines, false);
        els.push(el);
        io.observe(el);
      });
      if (!els.length) return;

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

      // 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._tuf.width) resplit(el); });
        }, 200);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-text-unfold>
  Each line opens out flat,
  like a letter taken from its envelope.
</p>