← Text reveals

Kinetic Poster Type

Words slam in one at a time, each line sized to fill the full width, until they lock into a tight block like a type poster. Short lines come out huge, long ones smaller, and every hit jolts the whole poster.

Make it big loud and bold

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-kinetic-poster-type to a short phrase in a heavy font.
  3. Give it a height to make the poster fit inside it; without one it sizes to the width.

Options

Add any of these attributes to the same element.

data-colorsLine colours, comma-separated, cycled line by line. Default currentColor,#a855f7.
data-widthThe widest the block gets, in px. Default 560.
data-maxThe biggest a line may be, as a share of the width. Lower it for more, smaller lines. Default 0.42.
data-intervalGap between words. Default 260 ms.
data-loopClear the poster and slam it in again.
data-holdHow long the poster stays, when looping. Default 1800 ms.
data-delayWait before starting. Default 0 ms.

It looks best in capitals: add text-transform: uppercase to the element.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Kinetic Poster Type: words slam in one at a time, each line sized to
    // fill the full width, until they build a tight block like a type poster.
    // Short lines come out huge, long ones smaller.
    // Usage: <h1 data-kinetic-poster-type>MAKE IT BIG AND BOLD</h1>
    // Give the element a height and the poster scales to fit inside it.
    // Optional: data-colors (comma-separated, cycled line by line, default
    // "currentColor,#a855f7"), data-width (widest the block gets, px, default
    // 560), data-max (biggest a line may be, as a share of the width, default
    // 0.42), data-interval (ms between words, default 260), data-loop (clear
    // and slam again), data-hold (ms to stay, when looping, default 1800),
    // data-delay (ms).
    var DEFAULTS = { colors: 'currentColor,#a855f7', width: 560, max: 0.42, interval: 260, hold: 1800, delay: 0 };
    var LEADING = 0.9;

    var css =
      '.kpt{display:flex;align-items:center;justify-content:center}' +
      '.kpt-block{display:block;flex:none;text-align:center}' +
      '.kpt-line{display:block;white-space:nowrap;line-height:' + LEADING + ';letter-spacing:-.03em}' +
      '.kpt-w{display:inline-block;will-change:transform,opacity}' +
      '.kpt-w+.kpt-w{margin-left:.2em}';
    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();
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var max = +d.max || DEFAULTS.max;
      el.setAttribute('aria-label', text);
      el.textContent = '';
      el.classList.add('kpt');
      // An element with its own height (from CSS) is a frame to fit inside.
      var fixed = el.clientHeight > 0;
      var block = document.createElement('span');
      block.className = 'kpt-block';
      block.setAttribute('aria-hidden', 'true');
      el.appendChild(block);

      // Measure each candidate line at 100px to find the font size that fills
      // the width, as a share of the width. Add words to a line until it is
      // no bigger than data-max.
      var probe = document.createElement('span');
      probe.className = 'kpt-line';
      probe.style.cssText = 'display:inline-block;font-size:100px;position:absolute;visibility:hidden';
      el.appendChild(probe);
      function ratio(ws) {
        probe.innerHTML = ws.map(function (w) { return '<span class="kpt-w">' + w.replace(/</g, '&lt;') + '</span>'; }).join('');
        return 100 / Math.max(1, probe.offsetWidth);
      }
      var groups = [], cur = [];
      text.split(' ').forEach(function (w, i, all) {
        cur.push(w);
        if (ratio(cur) <= max || i === all.length - 1) {
          groups.push({ words: cur, r: Math.min(max, ratio(cur)) });
          cur = [];
        }
      });
      el.removeChild(probe);

      var words = [];
      groups.forEach(function (g, i) {
        var line = document.createElement('span');
        line.className = 'kpt-line';
        line.style.color = colors[i % colors.length];
        g.words.forEach(function (w) {
          var s = document.createElement('span');
          s.className = 'kpt-w';
          s.textContent = w;
          line.appendChild(s);
          words.push(s);
        });
        block.appendChild(line);
        g.line = line;
      });
      var s = { block: block, groups: groups, words: words, fixed: fixed, el: el };
      layout(s);
      return s;
    }

    // Size the block to the element (and its height, if it has one), then
    // every line to fill the block's width.
    function layout(s) {
      var d = s.el.dataset;
      var w = Math.min(s.el.clientWidth, +d.width || DEFAULTS.width);
      var sum = s.groups.reduce(function (a, g) { return a + g.r; }, 0);
      if (s.fixed && sum) w = Math.min(w, s.el.clientHeight / (LEADING * sum));
      s.block.style.width = w + 'px';
      s.groups.forEach(function (g) { g.line.style.fontSize = (g.r * w * 0.995).toFixed(2) + 'px'; });
    }

    function play(el) {
      var s = el._kpt, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var interval = +d.interval || DEFAULTS.interval;
      var tl = anime.createTimeline({
        onComplete: function () { if (loop) setTimeout(function () { play(el); }, 300); }
      });
      anime.utils.set(s.words, { opacity: 0 });
      s.words.forEach(function (w, i) {
        var t = i * interval;
        // Each word drops in from oversized, hits hard and settles.
        tl.add(w, {
          opacity: [0, 1],
          scale: [2.6, 1],
          rotate: [anime.utils.random(-12, 12), 0],
          duration: 230,
          ease: 'in(2)'
        }, t);
        // The impact jolts the whole poster.
        tl.add(s.block, { y: [0, 3, -1.5, 0], x: [0, i % 2 ? 2 : -2, 0], duration: 180, ease: 'out(2)' }, t + 220);
      });
      if (loop) {
        var out = (s.words.length - 1) * interval + 400 + (+d.hold || DEFAULTS.hold);
        tl.add(s.words, {
          opacity: 0,
          scale: 0.7,
          duration: 260,
          delay: anime.stagger(35, { from: 'last' }),
          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 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) { if (e.target._kpt) layout(e.target._kpt); });
      });
      var run = function () {
        document.querySelectorAll('[data-kinetic-poster-type]').forEach(function (el) {
          if (el._kpt) return;
          var s = el._kpt = setup(el);
          ro.observe(el);
          if (reduced()) return;
          s.words.forEach(function (w) { w.style.opacity = 0; });
          io.observe(el);
        });
      };
      // Lines are measured, so wait for the web font.
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(run);
      else run();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-kinetic-poster-type style="text-transform: uppercase">Make it big loud and bold</h1>

<!-- or, in three colours, inside a fixed frame: -->
<h1 data-kinetic-poster-type data-colors="#111,#e11d48,#a855f7" style="height: 320px">Make it big loud and bold</h1>