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
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-night-visionand give the wrapper a size. The image fills it, cropped to cover. - 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-shape | lens (default) for a round vignette, binocular for two overlapping circles, or none. |
data-noise | How much sensor noise, from 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: mode, zoom, a blinking REC and a timecode. |
<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><figure data-night-vision data-hud style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>