Neon Edge Glow
Turn the edges in a picture into neon. The script traces every outline and lights it as a glowing tube on a dark wall, in the colour of the picture under it. The tubes stutter on section by section when they scroll into view, then hum, with the odd flicker.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-neon-edge-glowand 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. Pictures with clear shapes give the cleanest tubes.
Options
Add any of these attributes to the same element.
data-color | auto (default) takes each tube’s colour from the picture; or set one colour, such as #ff3df0. |
data-threshold | How strong an edge must be to light up, from 0 to 1. Default 0.12; higher keeps only the boldest lines. |
data-glow | How far the glow spreads. Default 1. |
data-dim | How much of the picture shows faintly behind the tubes, from 0 to 1. Default 0.12. |
data-loop | Switch off now and then and stutter back on. |
data-hold | How long it stays lit when looping. Default 4000 ms. |
With reduced motion the tubes are simply lit, without the stutter or the flicker.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Neon Edge Glow: finds the edges in an image and lights them up as glowing
// neon tubes on a dark wall, each tube in the colour of the picture
// beneath it. The tubes stutter on section by section when scrolled into
// view, then hum, with the odd flicker.
// Usage: <figure data-neon-edge-glow><img src="photo.jpg" alt="…"></figure>
// The image (an <img> on the same site, or an inline <svg>) stays in the
// page for screen readers; give the wrapper a size.
// Optional: data-color ("auto", the default, takes each tube's colour
// from the picture; or any colour, e.g. "#ff3df0"), data-threshold (how
// strong an edge must be to light up, 0 to 1, default 0.12), data-glow
// (how far the glow spreads, default 1), data-dim (how much of the
// picture shows behind the tubes, 0 to 1, default 0.12), data-loop
// (switch off now and then and stutter back on), data-hold (ms it stays
// lit when looping, default 4000).
var DEFAULTS = { threshold: 0.12, glow: 1, dim: 0.12, hold: 4000, maxSize: 640, zones: 4 };
var css =
'.neg{position:relative;overflow:hidden;background:#07060c}' +
'.neg-src{opacity:0}' +
'.neg-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 parseColor(c) {
var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((c || '').trim());
if (!m) return null;
var s = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
}
// Push a colour to full saturation and neon brightness.
function neon(r, g, b) {
var mx = Math.max(r, g, b), mn = Math.min(r, g, b);
if (mx - mn < 18) return [200, 235, 255];
var k = 255 / mx;
r = (r - mn) * k; g = (g - mn) * k; b = (b - mn) * k;
// Keep a little white in it, as real tubes have.
return [r * 0.85 + 38, g * 0.85 + 38, b * 0.85 + 38];
}
function setup(el, img, iw, ih) {
var d = el.dataset;
var fixed = d.color && d.color !== 'auto' ? parseColor(d.color) : null;
var threshold = d.threshold !== undefined ? +d.threshold : DEFAULTS.threshold;
var glow = d.glow !== undefined ? +d.glow : DEFAULTS.glow;
var dim = d.dim !== undefined ? +d.dim : DEFAULTS.dim;
var hold = +d.hold || DEFAULTS.hold;
var loop = d.loop !== undefined && d.loop !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'neg-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var tubes = document.createElement('canvas');
var wide = document.createElement('canvas');
var near = document.createElement('canvas');
var photo = document.createElement('canvas');
var w = 0, h = 0, dpr = 1;
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);
var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h)) * Math.min(1.5, dpr);
var lw = Math.max(3, Math.round(w * s)), lh = Math.max(3, Math.round(h * s));
photo.width = tubes.width = lw;
photo.height = tubes.height = lh;
var px = photo.getContext('2d', { willReadFrequently: true });
var k = Math.max(lw / iw, lh / ih);
px.drawImage(img, (lw - iw * k) / 2, (lh - ih * k) / 2, iw * k, ih * k);
var src;
try { src = px.getImageData(0, 0, lw, lh).data; }
catch (err) { canvas.remove(); el.querySelector('.neg-src').classList.remove('neg-src'); return false; }
// Edges: the strongest Sobel gradient across red, green and blue.
var tx = tubes.getContext('2d');
var out = tx.createImageData(lw, lh), o = out.data;
var x, y, c;
for (y = 1; y < lh - 1; y++) {
for (x = 1; x < lw - 1; x++) {
var best = 0, at = 0;
for (c = 0; c < 3; c++) {
var i = (y * lw + x) * 4 + c;
var tl = src[i - lw * 4 - 4], tc = src[i - lw * 4], tr = src[i - lw * 4 + 4];
var ml = src[i - 4], mr = src[i + 4];
var bl = src[i + lw * 4 - 4], bc = src[i + lw * 4], br = src[i + lw * 4 + 4];
var gx = tr + 2 * mr + br - tl - 2 * ml - bl;
var gy = bl + 2 * bc + br - tl - 2 * tc - tr;
var m = Math.sqrt(gx * gx + gy * gy) / 1020;
if (m > best) { best = m; at = gx * gx > gy * gy ? (gx > 0 ? 4 : -4) : (gy > 0 ? lw * 4 : -lw * 4); }
}
var a = (best - threshold) / Math.max(0.02, threshold);
if (a <= 0) continue;
a = Math.min(1, a);
// Colour from the brighter side of the edge, which is usually the coloured shape.
var p = (y * lw + x) * 4;
var q1 = p + at, q2 = p - at;
var lum1 = src[q1] + src[q1 + 1] + src[q1 + 2], lum2 = src[q2] + src[q2 + 1] + src[q2 + 2];
var q = lum1 > lum2 ? q1 : q2;
var col = fixed || neon(src[q], src[q + 1], src[q + 2]);
o[p] = col[0];
o[p + 1] = col[1];
o[p + 2] = col[2];
o[p + 3] = 255 * a;
}
}
tx.putImageData(out, 0, 0);
// Glows: the tubes shrunk and blown back up, which blurs them cheaply.
var g1 = Math.max(0.05, 0.28 / glow), g2 = Math.max(0.02, 0.09 / glow);
near.width = Math.max(1, Math.round(lw * g1));
near.height = Math.max(1, Math.round(lh * g1));
wide.width = Math.max(1, Math.round(lw * g2));
wide.height = Math.max(1, Math.round(lh * g2));
var nx = near.getContext('2d');
nx.imageSmoothingQuality = 'high';
nx.drawImage(tubes, 0, 0, near.width, near.height);
var wx = wide.getContext('2d');
wx.imageSmoothingQuality = 'high';
wx.drawImage(near, 0, 0, wide.width, wide.height);
return true;
}
// lit[i]: how bright each section (a vertical strip) is, 0 to 1.
function draw(lit, hum) {
var W = canvas.width, H = canvas.height, n = lit.length;
// When every section is equally bright, one pass over the whole picture does.
if (lit.every(function (v) { return v === lit[0]; })) { lit = [lit[0]]; n = 1; }
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.fillStyle = '#07060c';
ctx.fillRect(0, 0, W, H);
if (dim > 0) {
ctx.globalAlpha = dim;
ctx.drawImage(photo, 0, 0, W, H);
ctx.globalAlpha = 1;
}
ctx.globalCompositeOperation = 'lighter';
ctx.imageSmoothingEnabled = true;
for (var i = 0; i < n; i++) {
var b = lit[i] * hum;
if (b <= 0.01) continue;
var x0 = Math.floor((W * i) / n), x1 = Math.ceil((W * (i + 1)) / n);
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.globalAlpha = Math.min(1, b * 0.9);
ctx.drawImage(wide, 0, 0, W, H);
ctx.drawImage(wide, 0, 0, W, H);
ctx.globalAlpha = Math.min(1, b);
ctx.drawImage(near, 0, 0, W, H);
ctx.drawImage(near, 0, 0, W, H);
ctx.drawImage(tubes, 0, 0, W, H);
// Second pass on the tubes themselves, so their cores burn nearly white.
ctx.globalAlpha = Math.min(1, b * 0.7);
ctx.drawImage(tubes, 0, 0, W, H);
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
var zones = DEFAULTS.zones;
var lit = [];
for (var z = 0; z < zones; z++) lit.push(reduced() ? 1 : 0);
if (!build()) return;
draw(lit, 1);
if (window.ResizeObserver) {
var lw0 = el.clientWidth, lh0 = el.clientHeight;
new ResizeObserver(function () {
if (el.clientWidth === lw0 && el.clientHeight === lh0) return;
lw0 = el.clientWidth; lh0 = el.clientHeight;
if (build()) draw(lit, 1);
}).observe(el);
}
if (reduced()) return;
// Each section has its own switch-on stutter: a few blinks, then on for good.
var schedule = [];
function plan(t0) {
schedule = [];
for (var i = 0; i < zones; i++) {
var t = t0 + 250 + Math.random() * 900, steps = [];
var blinks = 2 + Math.floor(Math.random() * 3);
for (var b = 0; b < blinks; b++) {
steps.push([t, 0.4 + Math.random() * 0.6]);
t += 40 + Math.random() * 90;
steps.push([t, Math.random() * 0.15]);
t += 60 + Math.random() * 200;
}
steps.push([t, 1]);
schedule.push(steps);
}
}
var clock = 0, last = 0, cycleStart = 0, phase = 'on', flick = -1, flickUntil = 0;
plan(0);
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;
clock += dt;
var t = clock - cycleStart, i, s;
if (phase === 'on') {
var allOn = true;
for (i = 0; i < zones; i++) {
var v = 0, steps = schedule[i];
for (s = 0; s < steps.length; s++) if (t >= steps[s][0]) v = steps[s][1];
if (v < 1) allOn = false;
lit[i] = v;
}
// Once lit, an occasional flicker in one section.
if (allOn) {
if (flick < 0 && Math.random() < dt / 2600) { flick = (Math.random() * zones) | 0; flickUntil = clock + 90 + Math.random() * 160; }
if (flick >= 0) {
lit[flick] = clock < flickUntil ? 0.25 + Math.random() * 0.4 : 1;
if (clock >= flickUntil) flick = -1;
}
if (loop && t > schedule.reduce(function (m, st) { return Math.max(m, st[st.length - 1][0]); }, 0) + hold) {
phase = 'off';
cycleStart = clock;
}
}
} else {
// Switch off with a quick dimming, wait in the dark, then stutter back on.
for (i = 0; i < zones; i++) lit[i] = Math.max(0, 1 - t / 220);
if (t > 1100) { phase = 'on'; cycleStart = clock; plan(0); }
}
draw(lit, 0.95 + 0.05 * Math.sin(clock / 70) * Math.random());
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); }
else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-neon-edge-glow]').forEach(function (el) {
if (el._neg) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._neg = true;
el.classList.add('neg');
src.classList.add('neg-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-neon-edge-glow style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>