← Text reveals

Tracking In

Text fades in from a soft blur while its letter spacing tightens from wide to normal, drawing the letters together. Short headings stay on one line and stay centred the whole way.

Breathe in

How to use it

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

Options

Add any of these attributes to the same element.

data-loopTighten in, hold, open back out as it fades, and in again.
data-holdHow long the text stays before fading, when looping. Default 1800 ms.
data-spacingHow much wider the spacing starts, in em. Default 0.5.
data-durationHow long the spacing takes to tighten. Default 1400 ms.
data-delayWait before starting. Default 0 ms.

Looks especially good on short uppercase headings.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tracking In: text fades in from a soft blur while its letter spacing
    // tightens from wide to normal, drawing the letters together.
    // Usage: <h1 data-tracking-in>Breathe in</h1>
    // Optional: data-loop (spacing opens back out as the text fades, then
    // tightens again), data-hold (ms to stay before fading), data-spacing (how
    // wide it starts, in em; default 0.5), data-duration (ms), data-delay (ms
    // before starting).
    var DEFAULTS = { spacing: 0.5, duration: 1400, delay: 0, hold: 1800 };

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

    // The element's own letter spacing, in em, so it scales with the font.
    function baseSpacing(el) {
      var cs = getComputedStyle(el);
      var px = parseFloat(cs.letterSpacing) || 0;
      return px / parseFloat(cs.fontSize);
    }

    // True when the text sits on one line at rest. Then it's kept on one line
    // while the spacing is wide, instead of wrapping and jumping about.
    function fitsOneLine(el) {
      var h = el.offsetHeight;
      el.style.whiteSpace = 'nowrap';
      var one = el.offsetHeight === h;
      el.style.whiteSpace = '';
      return one;
    }

    function play(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var base = baseSpacing(el);
      var wide = base + (+d.spacing || DEFAULTS.spacing);
      var oneLine = fitsOneLine(el);
      // Letter spacing also adds space after the last letter, which pushes
      // centred text left. An equal indent on the left keeps it centred.
      var centred = oneLine && getComputedStyle(el).textAlign === 'center';

      if (oneLine) el.style.whiteSpace = 'nowrap';
      var into = {
        letterSpacing: [wide + 'em', base + 'em'],
        opacity: [0, 1],
        filter: ['blur(8px)', 'blur(0px)'],
        duration: duration,
        ease: 'outExpo'
      };
      if (centred) into.textIndent = [wide - base + 'em', '0em'];

      var tl = anime.createTimeline({
        loop: loop,
        loopDelay: 300,
        delay: +d.delay || DEFAULTS.delay,
        onComplete: function () {
          // Hand the spacing back to the stylesheet.
          ['letterSpacing', 'textIndent', 'filter', 'whiteSpace'].forEach(function (p) { el.style[p] = ''; });
        }
      });
      tl.add(el, into);
      if (loop) {
        var out = {
          letterSpacing: wide + 'em',
          opacity: 0,
          filter: 'blur(8px)',
          duration: duration * 0.6,
          ease: 'inQuad'
        };
        if (centred) out.textIndent = wide - base + 'em';
        tl.add(el, out, '+=' + (+d.hold || DEFAULTS.hold));
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-tracking-in]').forEach(function (el) {
        if (el._trk) return;
        el._trk = true;
        if (reduced()) return;
        el.style.opacity = 0;
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-tracking-in>Breathe in</h1>

<!-- or, looping: -->
<h1 data-tracking-in data-loop>Breathe in</h1>