← Filters & retro

ASCII Image

Render an image, or a playing video, live in ASCII characters: denser letters where the picture is brighter, each tinted with the colour underneath. It decodes into place from random characters, and the letters scramble and glow around the pointer.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image or a <video autoplay muted loop playsinline> in an element with data-ascii-image, and give the wrapper a size.
  3. Use a file from your own site (or an inline SVG): the script has to read its pixels. A video is redrawn every frame.

Options

Add any of these attributes to the same element.

data-sizeFont size of the characters. Default 10 px.
data-charsThe characters to use, dark to bright. Default  .:-=+*#%@.
data-colorsource (default) tints each character with the picture, or any CSS colour, like #4ade80.
data-bgBackground colour. Default #08080c.
data-autoplayA pretend pointer drifts across while nobody is pointing. Handy on phones.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // ASCII Image: renders an image or a playing video live in ASCII
    // characters, denser letters where the picture is brighter, each tinted
    // with the colour beneath it. It decodes into place from random characters
    // when it scrolls into view, and the characters scramble and glow around
    // the pointer (or a finger). A video is redrawn every frame.
    // Usage: <figure data-ascii-image><img src="photo.jpg" alt="…"></figure>
    //    or: <div data-ascii-image><video src="clip.mp4" autoplay muted loop playsinline></video></div>
    // The source (an <img> or <video> on the same site, or an inline <svg>) fills
    // the box and is hidden behind the characters.
    // Optional: data-size (font size in px, default 10), data-chars (dark to
    // bright, default " .:-=+*#%@"), data-color ("source", the default, tints each
    // character with the picture; or any CSS colour), data-bg (background,
    // default #08080c), data-autoplay (a pretend pointer drifts across while
    // nobody is pointing).
    var DEFAULTS = { size: 10, chars: ' .:-=+*#%@', color: 'source', bg: '#08080c', radius: 70 };
    var NOISE = '!#$%&*+/<=>?@[]^{}~ABCDEFGHJKLMNPQRSTUVWXYZ0123456789';

    var css =
      '.asc{position:relative;overflow:hidden}' +
      '.asc-src{opacity:0}' +
      '.asc-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Turn the source into something drawImage can use. Videos are used as they are.
    function loadSource(node, done) {
      var tag = node.tagName.toLowerCase();
      if (tag === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1000 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (tag === 'video') {
        if (node.readyState >= 2) done(node, node.videoWidth, node.videoHeight);
        else node.addEventListener('loadeddata', function () { done(node, node.videoWidth, node.videoHeight); }, { once: true });
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var size = +d.size || DEFAULTS.size;
      var chars = d.chars || DEFAULTS.chars;
      var color = d.color || DEFAULTS.color;
      var bg = d.bg || DEFAULTS.bg;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var video = img.tagName && img.tagName.toLowerCase() === 'video';
      var canvas = document.createElement('canvas');
      canvas.className = 'asc-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var small = document.createElement('canvas');
      var sctx = small.getContext('2d', { willReadFrequently: true });
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, cw = 0, ox = 0, oy = 0;
      var cells = [], t = 0, decodeStart = -1, decodeLen = 1400;
      var pointer = { x: 0, y: 0, k: 0 }, real = null, lastReal = -1e9;
      var broken = false;

      // Read the picture into one cell per character: its brightness and colour.
      function sample() {
        cover(sctx, img, iw, ih, cols, rows);
        var p;
        try { p = sctx.getImageData(0, 0, cols, rows).data; }
        catch (err) { broken = true; canvas.remove(); el.querySelector('.asc-src').classList.remove('asc-src'); return; }
        // Stretch the brightness to the full range, so every character gets used.
        var lums = new Float32Array(cells.length), lo = 1, hi = 0, i;
        for (i = 0; i < cells.length; i++) {
          var l = (0.299 * p[i * 4] + 0.587 * p[i * 4 + 1] + 0.114 * p[i * 4 + 2]) / 255;
          lums[i] = l;
          if (l < lo) lo = l;
          if (l > hi) hi = l;
        }
        var span = Math.max(0.05, hi - lo);
        for (i = 0; i < cells.length; i++) {
          var c = cells[i], j = i * 4;
          var lum = (lums[i] - lo) / span;
          c.ch = chars[Math.min(chars.length - 1, Math.floor(lum * chars.length))];
          // Lift the colours a little so dim characters still read.
          var lift = 0.35;
          c.col = 'rgb(' + Math.round(p[j] + (255 - p[j]) * lift * lum) + ',' + Math.round(p[j + 1] + (255 - p[j + 1]) * lift * lum) + ',' + Math.round(p[j + 2] + (255 - p[j + 2]) * lift * lum) + ')';
        }
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        cw = size * 0.6;
        cols = Math.max(1, Math.floor(w / cw));
        rows = Math.max(1, Math.floor(h / size));
        ox = (w - cols * cw) / 2;
        oy = (h - rows * size) / 2;
        small.width = cols;
        small.height = rows;
        cells = [];
        for (var i = 0; i < cols * rows; i++) cells.push({ ch: ' ', col: '#fff', at: Math.random() });
        sample();
      }

      function draw() {
        if (broken) return;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = bg;
        ctx.fillRect(0, 0, w, h);
        ctx.font = '600 ' + size + 'px ui-monospace, Menlo, Consolas, monospace';
        ctx.textBaseline = 'top';
        var decoding = decodeStart >= 0 ? (t - decodeStart) / decodeLen : 1;
        var R = DEFAULTS.radius * Math.max(0.7, Math.min(1.5, w / 500)), R2 = R * R;
        if (color !== 'source') ctx.fillStyle = color;
        for (var y = 0; y < rows; y++) {
          for (var x = 0; x < cols; x++) {
            var c = cells[y * cols + x], ch = c.ch, px = ox + x * cw, py = oy + y * size, hot = 0;
            if (pointer.k > 0.01) {
              var dx = px - pointer.x, dy = py - pointer.y, q = (dx * dx + dy * dy) / R2;
              if (q < 1) hot = (1 - q) * pointer.k;
            }
            // Not decoded yet, or near the pointer: a random character.
            if (decoding < c.at || (hot > 0.15 && Math.random() < hot * 0.7)) {
              ch = NOISE[(Math.random() * NOISE.length) | 0];
              if (decoding < c.at && decoding < c.at - 0.25) { if (Math.random() < 0.6) continue; }
            }
            if (ch === ' ') continue;
            if (color === 'source') ctx.fillStyle = hot > 0.15 ? '#fff' : c.col;
            ctx.fillText(ch, px, py);
          }
        }
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
      if (reduced()) { draw(); return; }
      decodeStart = 0;
      draw();

      function move(e) {
        var r = el.getBoundingClientRect();
        real = { x: e.clientX - r.left, y: e.clientY - r.top };
        lastReal = t;
      }
      el.addEventListener('pointermove', move);
      el.addEventListener('pointerdown', move);
      el.addEventListener('pointerleave', function () { real = null; });
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') real = null; });

      var last = 0, frame = 0, seen = false;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          t += dt;
          // Draw at about 30 frames a second: plenty for flickering letters.
          if ((frame++ & 1) === 1) return;
          var target = null;
          if (real) target = real;
          else if (autoplay && t - lastReal > 2500 && t > decodeLen) {
            target = { x: w * (0.5 + 0.38 * Math.sin(t / 1900)), y: h * (0.5 + 0.3 * Math.sin(t / 1300 + 2)) };
          }
          var e = 1 - Math.pow(0.85, dt / 16 * 2);
          if (target) {
            if (pointer.k < 0.02) { pointer.x = target.x; pointer.y = target.y; }
            pointer.x += (target.x - pointer.x) * e;
            pointer.y += (target.y - pointer.y) * e;
          }
          pointer.k += ((target ? 1 : 0) - pointer.k) * e * 0.5;
          if (video && !broken) sample();
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!seen) { seen = true; t = 0; decodeStart = 0; }
          last = 0;
          timer.play();
        } else timer.pause();
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-ascii-image]').forEach(function (el) {
        if (el._asc) return;
        var src = el.querySelector('img, svg, video');
        if (!src) return;
        el._asc = true;
        el.classList.add('asc');
        src.classList.add('asc-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-ascii-image style="height:400px">
  <img src="photo.jpg" alt="Sand dunes">
</figure>

<!-- or, live video: -->
<div data-ascii-image style="height:400px">
  <video src="clip.mp4" autoplay muted loop playsinline></video>
</div>