← Filters & retro

Night Vision

See an image through night-vision goggles. Everything goes phosphor green with the shadows lifted, live sensor noise crawls over it, fine scanlines and a dark lens sit on top and the brightness flickers. It powers up with a green flash when it scrolls into view.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-night-vision and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.

Options

Add any of these attributes to the same element.

data-shapelens (default) for a round vignette, binocular for two overlapping circles, or none.
data-noiseHow much sensor noise, from 0 to 2. Default 1.
data-gainHow much the shadows are lifted. Default 1.4.
data-hudAdds a small read-out in the corners: mode, zoom, a blinking REC and a timecode.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Night Vision: turns an image into the view through night-vision goggles.
    // Everything goes phosphor green with the shadows lifted, a live sensor
    // noise crawls over it, fine scanlines and a dark lens vignette sit on top,
    // and the brightness flickers slightly. It powers up with a green flash.
    // Usage: <figure data-night-vision><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box,
    // cropped to cover, and stays in the page for screen readers.
    // Optional: data-shape ("lens", the default, a round vignette;
    // "binocular" for two overlapping circles; "none"), data-noise (0 to 2,
    // default 1), data-gain (how much the shadows are lifted, default 1.4),
    // data-hud (adds a small read-out in the corners).
    var DEFAULTS = { noise: 1, gain: 1.4, maxSize: 520, frames: 5 };

    var css =
      '.nvs{position:relative;overflow:hidden;background:#010802}' +
      '.nvs-src{opacity:0}' +
      '.nvs-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 (<img> or inline <svg>) into something drawImage can use.
    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === '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 (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 shape = d.shape || 'lens';
      var noise = d.noise !== undefined ? +d.noise : DEFAULTS.noise;
      var gain = d.gain !== undefined ? +d.gain : DEFAULTS.gain;
      var hud = d.hud !== undefined && d.hud !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'nvs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var green = document.createElement('canvas');
      var gctx = green.getContext('2d', { willReadFrequently: true });
      var overlay = document.createElement('canvas');
      var grains = [];
      var w = 0, h = 0, dpr = 1, start = 0;

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return false;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        // The green picture, made once at a modest size; upscaling softens it like a real tube.
        var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h));
        green.width = Math.max(1, Math.round(w * s));
        green.height = Math.max(1, Math.round(h * s));
        cover(gctx, img, iw, ih, green.width, green.height);
        var data;
        try { data = gctx.getImageData(0, 0, green.width, green.height); }
        catch (err) { canvas.remove(); el.querySelector('.nvs-src').classList.remove('nvs-src'); return false; }
        var p = data.data, lo = 1, hi = 0, i, l;
        for (i = 0; i < p.length; i += 4) {
          l = (0.299 * p[i] + 0.587 * p[i + 1] + 0.114 * p[i + 2]) / 255;
          if (l < lo) lo = l;
          if (l > hi) hi = l;
        }
        // Stretch the tones to the full range first, so dim and flat pictures still read.
        var span = Math.max(0.1, hi - lo);
        for (i = 0; i < p.length; i += 4) {
          l = ((0.299 * p[i] + 0.587 * p[i + 1] + 0.114 * p[i + 2]) / 255 - lo) / span;
          l = Math.min(1, Math.pow(l, 1 / gain) * 1.05);
          p[i] = 8 + l * 150;
          p[i + 1] = 22 + l * 233;
          p[i + 2] = 10 + l * 130;
        }
        gctx.putImageData(data, 0, 0);
        // A few frames of noise, shuffled every frame.
        grains = [];
        var gw = Math.ceil(w / 2), gh = Math.ceil(h / 2);
        for (var f = 0; f < DEFAULTS.frames; f++) {
          var c = document.createElement('canvas');
          c.width = gw;
          c.height = gh;
          var x = c.getContext('2d');
          var nd = x.createImageData(gw, gh), q = nd.data;
          for (var j = 0; j < q.length; j += 4) {
            var v = Math.random();
            v = v * v * v * v;
            q[j] = 120 * v; q[j + 1] = 255 * v; q[j + 2] = 120 * v;
            q[j + 3] = 255 * Math.min(1, v * 1.6 + (Math.random() < 0.004 ? 1 : 0));
          }
          x.putImageData(nd, 0, 0);
          grains.push(c);
        }
        // Scanlines, lens and read-out don't change, so draw them once.
        overlay.width = canvas.width;
        overlay.height = canvas.height;
        var o = overlay.getContext('2d');
        o.setTransform(dpr, 0, 0, dpr, 0, 0);
        var y;
        if (shape === 'binocular') {
          // Black everywhere except the union of two overlapping circles.
          var r = Math.min(h * 0.52, w * 0.3), cy = h / 2;
          o.fillStyle = '#000';
          o.fillRect(0, 0, w, h);
          o.globalCompositeOperation = 'destination-out';
          o.filter = 'blur(' + Math.round(r * 0.12) + 'px)';
          o.beginPath();
          o.arc(w / 2 - r * 0.62, cy, r, 0, Math.PI * 2);
          o.moveTo(w / 2 + r * 0.62 + r, cy);
          o.arc(w / 2 + r * 0.62, cy, r, 0, Math.PI * 2);
          o.fill();
          o.filter = 'none';
          o.globalCompositeOperation = 'source-over';
          o.fillStyle = 'rgba(0,0,0,.2)';
          for (y = 0; y < h; y += 3) o.fillRect(0, y, w, 1);
        } else {
          o.fillStyle = 'rgba(0,0,0,.2)';
          for (y = 0; y < h; y += 3) o.fillRect(0, y, w, 1);
        }
        if (shape !== 'binocular' && shape !== 'none') {
          var R = Math.hypot(w, h) / 2;
          var g = o.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, R);
          g.addColorStop(0, 'rgba(0,0,0,0)');
          g.addColorStop(0.75, 'rgba(0,0,0,.55)');
          g.addColorStop(1, 'rgba(0,0,0,.95)');
          o.fillStyle = g;
          o.fillRect(0, 0, w, h);
        }
        if (hud) {
          var fs = Math.max(9, Math.min(13, w / 40));
          o.font = '600 ' + fs + 'px ui-monospace, Menlo, Consolas, monospace';
          o.fillStyle = 'rgba(190,255,190,.85)';
          o.shadowColor = 'rgba(120,255,120,.8)';
          o.shadowBlur = 6;
          o.textBaseline = 'top';
          var pad = Math.max(10, w * 0.035);
          o.fillText('NV-2  IR ON', pad, pad);
          o.textAlign = 'right';
          o.fillText('×4.0', w - pad, pad);
          o.shadowBlur = 0;
        }
        return true;
      }

      var t = 0, timecode = 0;
      function draw(intro) {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 1;
        ctx.imageSmoothingEnabled = true;
        // A slight, irregular flicker in brightness.
        var flick = 0.9 + 0.1 * Math.random() * Math.random() + 0.03 * Math.sin(t / 90);
        ctx.fillStyle = '#010802';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.globalAlpha = Math.min(1, flick);
        ctx.drawImage(green, 0, 0, canvas.width, canvas.height);
        ctx.globalAlpha = 1;
        if (noise > 0 && grains.length) {
          ctx.globalCompositeOperation = 'lighter';
          ctx.globalAlpha = Math.min(1, 0.22 * noise);
          var gfr = grains[(Math.random() * grains.length) | 0];
          var ox = (Math.random() * 20) | 0, oy = (Math.random() * 20) | 0;
          ctx.imageSmoothingEnabled = false;
          ctx.drawImage(gfr, -ox, -oy, canvas.width + 40, canvas.height + 40);
          ctx.imageSmoothingEnabled = true;
          ctx.globalAlpha = 1;
        }
        // The power-up: a green flash that settles.
        if (intro < 1) {
          ctx.globalCompositeOperation = 'lighter';
          ctx.globalAlpha = Math.pow(1 - intro, 2) * 0.9;
          ctx.fillStyle = '#7dff8a';
          ctx.fillRect(0, 0, canvas.width, canvas.height);
          ctx.globalAlpha = 1;
        }
        ctx.globalCompositeOperation = 'source-over';
        ctx.drawImage(overlay, 0, 0);
        if (hud) {
          var fs = Math.max(9, Math.min(13, w / 40)), pad = Math.max(10, w * 0.035);
          var sec = Math.floor(timecode / 1000);
          var tc = '00:' + ('0' + Math.floor(sec / 60) % 60).slice(-2) + ':' + ('0' + sec % 60).slice(-2);
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          ctx.font = '600 ' + fs + 'px ui-monospace, Menlo, Consolas, monospace';
          ctx.textBaseline = 'bottom';
          ctx.textAlign = 'left';
          ctx.fillStyle = 'rgba(190,255,190,.85)';
          if (Math.floor(t / 600) % 2 === 0) {
            ctx.fillStyle = '#ff5a5a';
            ctx.beginPath();
            ctx.arc(pad + fs * 0.4, h - pad - fs * 0.55, fs * 0.36, 0, Math.PI * 2);
            ctx.fill();
            ctx.fillStyle = 'rgba(190,255,190,.85)';
          }
          ctx.fillText('REC', pad + fs, h - pad);
          ctx.textAlign = 'right';
          ctx.fillText(tc, w - pad, h - pad);
        }
      }

      if (!build()) return;
      if (window.ResizeObserver) {
        var lw = el.clientWidth, lh = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lw && el.clientHeight === lh) return;
          lw = el.clientWidth; lh = el.clientHeight;
          if (build()) draw(1);
        }).observe(el);
      }
      var still = reduced();
      noise = still ? noise * 0.6 : noise;
      draw(1);
      if (still) return;

      // About 24 frames a second, like a video feed.
      var last = 0, acc = 0, shown = false;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(60, now - last) : 16;
          last = now;
          t += dt;
          timecode += dt;
          acc += dt;
          if (acc < 40) return;
          acc = 0;
          draw(Math.min(1, (now - start) / 900));
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!shown) { shown = true; start = performance.now(); }
          last = 0;
          timer.play();
        } else timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-night-vision]').forEach(function (el) {
        if (el._nvs) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._nvs = true;
        el.classList.add('nvs');
        src.classList.add('nvs-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-night-vision data-hud style="height:400px">
  <img src="photo.jpg" alt="A mountain lake">
</figure>