← Text reveals

Smoke Reveal

Letters condense out of drifting smoke, one by one. Each arrives as a swirl of soft haze that blows in from the side, draws together and sharpens into the letter while the last wisps drift away.

Out of thin air

How to use it

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

Options

Add any of these attributes to the same element.

data-colorThe smoke’s colour. Default the text colour.
data-driftWhich way the smoke blows: right (default) or left.
data-loopCondense, hold, blow away into smoke and condense again.
data-holdHow long the text stays before blowing away, when looping. Default 1800 ms.
data-durationHow long each letter takes to condense. Default 1700 ms.
data-staggerGap between letters. Default 70 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 () {
    // Smoke Reveal: letters condense out of drifting smoke, one by one. Each
    // letter arrives as a swirl of soft haze that blows in from one side, draws
    // together and sharpens into the letter while the last wisps drift away.
    // Usage: <h1 data-smoke-reveal>Out of thin air</h1>
    // Optional: data-color (the smoke, default the text colour), data-drift
    // (which way the smoke blows: "right", the default, or "left"), data-loop
    // (blow away into smoke and condense again), data-hold (ms to stay before
    // blowing away), data-duration (ms per letter), data-stagger (ms between
    // letters), data-delay (ms before starting).
    var DEFAULTS = { drift: 'right', duration: 1700, stagger: 70, hold: 1800, delay: 0 };

    var css =
      '.smk-word{display:inline-block;white-space:nowrap}' +
      '.smk-char{display:inline-block;position:relative}' +
      '.smk-glyph{display:inline-block;will-change:transform,filter,opacity}' +
      '.smk-puff{position:absolute;left:50%;top:50%;width:1em;height:1em;margin:-.5em 0 0 -.5em;' +
      'border-radius:50%;pointer-events:none;opacity:0;will-change:transform,opacity}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var R = function (a, b) { return a + Math.random() * (b - a); };

    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 color = d.color || 'currentColor';
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var glyphs = [], puffs = [];
      text.split(' ').forEach(function (word, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'smk-word';
        w.setAttribute('aria-hidden', 'true');
        Array.from(word).forEach(function (c) {
          var ch = document.createElement('span');
          ch.className = 'smk-char';
          var g = document.createElement('span');
          g.className = 'smk-glyph';
          g.textContent = c;
          ch.appendChild(g);
          var mine = [];
          // Two soft puffs of haze per letter.
          for (var k = 0; k < 2; k++) {
            var p = document.createElement('span');
            p.className = 'smk-puff';
            p.style.background = 'radial-gradient(closest-side,' + color + ',transparent)';
            ch.appendChild(p);
            mine.push(p);
          }
          w.appendChild(ch);
          glyphs.push(g);
          puffs.push(mine);
        });
        el.appendChild(w);
      });
      return { glyphs: glyphs, puffs: puffs };
    }

    function play(el) {
      var d = el.dataset, s = el._smk;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = +d.stagger || DEFAULTS.stagger;
      var dir = (d.drift || DEFAULTS.drift) === 'left' ? -1 : 1;
      var fs = parseFloat(getComputedStyle(el).fontSize);
      var blur = (fs * 0.22).toFixed(1);

      var tl = anime.createTimeline({
        onComplete: function () {
          if (!loop) return;
          setTimeout(function () { away(); }, +d.hold || DEFAULTS.hold);
        }
      });
      s.glyphs.forEach(function (g, i) {
        var t = i * stagger;
        tl.add(g, {
          opacity: [0, 1],
          x: [-dir * fs * R(0.25, 0.45), 0],
          y: [fs * R(-0.12, 0.12), 0],
          scale: [1.45, 1],
          rotate: [dir * R(-14, 4), 0],
          filter: ['blur(' + blur + 'px)', 'blur(0px)'],
          duration: duration,
          ease: 'out(3)'
        }, t);
        s.puffs[i].forEach(function (p, k) {
          tl.add(p, {
            opacity: [0, 0.26, 0],
            x: [-dir * fs * R(0.5, 0.8), dir * fs * R(0.2, 0.6)],
            y: [fs * R(0, 0.25), -fs * R(0.15, 0.55)],
            scale: [R(0.4, 0.7), R(1.6, 2.3)],
            duration: duration * R(1, 1.3),
            ease: 'out(2)'
          }, Math.max(0, t - 120 + k * 160));
        });
      });

      function away() {
        var out = anime.createTimeline({ onComplete: function () { setTimeout(function () { play(el); }, 300); } });
        s.glyphs.forEach(function (g, i) {
          var t = i * stagger * 0.5;
          out.add(g, {
            opacity: 0,
            x: dir * fs * R(0.2, 0.4),
            y: -fs * R(0.05, 0.2),
            scale: 1.3,
            filter: 'blur(' + blur + 'px)',
            duration: duration * 0.6,
            ease: 'in(2)'
          }, t);
          out.add(s.puffs[i][0], {
            opacity: [0, 0.2, 0],
            x: [0, dir * fs * 0.7],
            y: [0, -fs * 0.4],
            scale: [0.8, 2],
            duration: duration * 0.8,
            ease: 'out(2)'
          }, t + 100);
        });
      }
    }

    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-smoke-reveal]').forEach(function (el) {
        if (el._smk) return;
        el._smk = setup(el);
        if (reduced()) return;
        el._smk.glyphs.forEach(function (g) { g.style.opacity = 0; });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-smoke-reveal>Out of thin air</h1>

<!-- or, with grey smoke blowing left, looping: -->
<h1 data-smoke-reveal data-color="#9ca3af" data-drift="left" data-loop>Out of thin air</h1>