← Text reveals

ASCII Reveal

The text builds up out of a grid of characters: first a scatter of dots, then denser and denser ASCII, until the shapes are solid enough to turn into the real letters.

Rendering

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ascii-reveal to a heading or short line of text.

Options

Add any of these attributes to the same element.

data-colorThe colour of the ASCII characters. Default the text colour.
data-cellSize of a grid cell, as a fraction of the font size. Default 0.06.
data-charsThe characters, from sparse to dense, starting with a space. Default  .:-=+*#%@.
data-durationTime to build up. Default 1600 ms.
data-loopBuild up, hold, break back down and build up again.
data-holdHow long the text stays solid, when looping. Default 2000 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 () {
    // ASCII Reveal: the text builds up out of a grid of characters, from sparse
    // dots through denser and denser ASCII to solid letters.
    // Usage: <h1 data-ascii-reveal>Rendering</h1>
    // Optional: data-color (the ASCII characters, default the text colour),
    // data-cell (grid cell size as a fraction of the font size, default 0.06),
    // data-chars (the ramp from sparse to dense, default " .:-=+*#%@"),
    // data-duration (ms to build up, default 1600), data-loop (break back down
    // and build up again), data-hold (ms to stay solid, when looping),
    // data-delay (ms before starting).
    var DEFAULTS = { cell: 0.06, chars: ' .:-=+*#%@', duration: 1600, hold: 2000, delay: 0 };

    var css =
      '.ascr-on{position:relative}' +
      '.ascr-txt{transition:opacity .45s ease}' +
      '.ascr-w{display:inline-block;white-space:nowrap}' +
      '.ascr-cv{position:absolute;pointer-events:none;transition:opacity .45s ease}';
    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 text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      el.classList.add('ascr-on');
      var txt = document.createElement('span');
      txt.className = 'ascr-txt';
      txt.setAttribute('aria-hidden', 'true');
      var words = text.split(' ').map(function (word, i) {
        if (i) txt.appendChild(document.createTextNode(' '));
        var w = document.createElement('span');
        w.className = 'ascr-w';
        w.textContent = word;
        txt.appendChild(w);
        return w;
      });
      el.appendChild(txt);
      var cv = document.createElement('canvas');
      cv.className = 'ascr-cv';
      cv.setAttribute('aria-hidden', 'true');
      el.appendChild(cv);
      return { txt: txt, words: words, cv: cv, cells: [] };
    }

    // Draws the words into an offscreen canvas and samples it into grid cells,
    // each with how much of it is covered by ink.
    function measure(el) {
      var s = el._ascr, d = el.dataset, cs = getComputedStyle(el);
      var fs = parseFloat(cs.fontSize);
      var size = Math.max(3, fs * (+d.cell || DEFAULTS.cell));
      var cw = size, chH = size * 1.25;
      var er = el.getBoundingClientRect();
      var pad = Math.ceil(size);
      var W = Math.ceil(er.width) + pad * 2, H = Math.ceil(er.height) + pad * 2;

      var m = document.createElement('canvas');
      m.width = W; m.height = H;
      var mx = m.getContext('2d', { willReadFrequently: true });
      mx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
      if ('letterSpacing' in mx) mx.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
      var tm = mx.measureText('Hg');
      var asc = tm.fontBoundingBoxAscent || fs * 0.8, desc = tm.fontBoundingBoxDescent || fs * 0.2;
      mx.fillStyle = '#000';
      s.words.forEach(function (w) {
        var r = w.getBoundingClientRect();
        mx.fillText(w.textContent, r.left - er.left + pad, r.top - er.top + pad + (r.height - asc - desc) / 2 + asc);
      });
      var data = mx.getImageData(0, 0, W, H).data;

      var cells = [];
      for (var y = 0; y + chH <= H; y += chH) {
        for (var x = 0; x + cw <= W; x += cw) {
          var hit = 0, n = 0;
          for (var sy = 0.2; sy < 1; sy += 0.3) {
            for (var sx = 0.2; sx < 1; sx += 0.3) {
              var px = (x + sx * cw) | 0, py = (y + sy * chH) | 0;
              hit += data[(py * W + px) * 4 + 3] / 255;
              n++;
            }
          }
          var cov = hit / n;
          if (cov < 0.06) continue;
          cells.push({ x: x + cw / 2, y: y + chH / 2, cov: cov, t: Math.random() * 0.3 + (x / W) * 0.35 });
        }
      }

      var dpr = Math.min(2, window.devicePixelRatio || 1);
      var cv = s.cv;
      cv.style.left = -pad + 'px';
      cv.style.top = -pad + 'px';
      cv.style.width = W + 'px';
      cv.style.height = H + 'px';
      cv.width = Math.round(W * dpr);
      cv.height = Math.round(H * dpr);
      var ctx = cv.getContext('2d');
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.font = '600 ' + (chH * 1.0).toFixed(1) + 'px ui-monospace, Menlo, Consolas, monospace';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillStyle = d.color || cs.color;
      s.ctx = ctx; s.W = W; s.H = H; s.cells = cells;
    }

    // p runs 0 (empty) to 1 (every cell at its densest character).
    function draw(el, p) {
      var s = el._ascr, ramp = el.dataset.chars || DEFAULTS.chars, top = ramp.length - 1;
      s.ctx.clearRect(0, 0, s.W, s.H);
      s.cells.forEach(function (c) {
        var level = Math.max(0, Math.min(1, (p * 1.05 - c.t) / 0.35)) * Math.sqrt(c.cov);
        var i = Math.round(level * top);
        if (level < 1 && i > 0 && Math.random() < 0.12) i += Math.random() < 0.5 ? -1 : 1;
        i = Math.max(0, Math.min(top, i));
        if (i === 0 || ramp[i] === ' ') return;
        s.ctx.fillText(ramp[i], c.x, c.y);
      });
    }

    function run(el, from, to, duration, done) {
      var start = performance.now();
      (function tick(now) {
        var q = Math.min(1, (now - start) / duration);
        draw(el, from + (to - from) * q);
        if (q < 1) requestAnimationFrame(tick);
        else if (done) done();
      })(start);
    }

    function play(el) {
      var s = el._ascr, d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      measure(el);
      s.cv.style.opacity = 1;
      run(el, 0, 1, duration, function () {
        // The densest grid holds a beat, then gives way to the real letters.
        setTimeout(function () {
          s.txt.style.opacity = 1;
          s.cv.style.opacity = 0;
        }, 200);
        if (!loop) return;
        setTimeout(function () {
          measure(el);
          draw(el, 1);
          s.cv.style.opacity = 1;
          s.txt.style.opacity = 0;
          setTimeout(function () {
            run(el, 1, 0, duration * 0.6, function () { setTimeout(function () { play(el); }, 400); });
          }, 300);
        }, +d.hold || DEFAULTS.hold);
      });
    }

    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 () {
            (document.fonts ? document.fonts.ready : Promise.resolve()).then(function () { play(el); });
          }, +el.dataset.delay || DEFAULTS.delay);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-ascii-reveal]').forEach(function (el) {
        if (el._ascr) return;
        el._ascr = setup(el);
        if (reduced()) return;
        el._ascr.txt.style.opacity = 0;
        io.observe(el);
      });
    }

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

<!-- or, with a coarser grid and a shorter ramp: -->
<h1 data-ascii-reveal data-cell="0.1" data-chars=" .oO@">Rendering</h1>