No Signal Screen
An old TV between channels. Snowy static crackles and rolls across the screen, then the tube flashes over to colour bars with a “no signal” card in the middle and a green channel read-out in the corner. Loop it for a waiting screen or a playful 404.
How to use it
- Paste the snippet just before
</head>. - Add
data-no-signal-screento an empty element and give it a size. The script draws the screen inside it.
Options
Add any of these attributes to the same element.
data-text | The words on the card. Default NO SIGNAL. |
data-channel | The green read-out in the corner. Default AV 1. |
data-static | How long the static runs before the bars. Default 1800 ms. |
data-loop | Go back to static after a while and round again. |
data-hold | How long the bars stay when looping. Default 3200 ms. |
With reduced motion it shows the colour bars and the card straight away, without the static.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// No Signal Screen: an old TV between channels. Static crackles and rolls
// across the screen, then the picture snaps to colour bars with a "no
// signal" card in the middle and a channel read-out in the corner.
// Usage: <div data-no-signal-screen style="height:320px"></div>
// Optional: data-text (the card's words, default "NO SIGNAL"), data-channel
// (the corner read-out, default "AV 1"), data-static (ms of static before
// the bars, default 1800), data-loop (back to static after a while and
// round again), data-hold (ms the bars stay when looping, default 3200).
var DEFAULTS = { text: 'NO SIGNAL', channel: 'AV 1', static: 1800, hold: 3200 };
var css =
'.nss{position:relative;overflow:hidden;background:#000}' +
'.nss-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Colour bars: seven bars on top, a thin strip of reversed bars, then the darker bottom row.
var TOP = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
var MID = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
function setup(el) {
var d = el.dataset;
var text = d.text || DEFAULTS.text;
var channel = d.channel || DEFAULTS.channel;
var staticMs = d.static !== undefined ? +d.static : DEFAULTS.static;
var hold = +d.hold || DEFAULTS.hold;
var loop = d.loop !== undefined && d.loop !== 'false';
if (!el.getAttribute('aria-label') && !el.closest('[aria-hidden="true"]')) {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'An old TV screen showing static, then colour bars and "' + text + '"');
}
var canvas = document.createElement('canvas');
canvas.className = 'nss-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var bars = document.createElement('canvas');
var snow = [];
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);
// Snow: a few frames of coarse grey noise with streaky rows, shuffled each frame.
snow = [];
var sw = Math.ceil(w / 2), sh = Math.ceil(h / 2);
for (var f = 0; f < 6; f++) {
var c = document.createElement('canvas');
c.width = sw;
c.height = sh;
var x = c.getContext('2d');
var id = x.createImageData(sw, sh), q = id.data;
for (var yy = 0; yy < sh; yy++) {
var row = 0.75 + Math.random() * 0.4;
var smear = 0;
for (var xx = 0; xx < sw; xx++) {
// A little horizontal smearing, as an analogue signal does.
smear = smear * 0.35 + Math.random() * 0.65;
var v = Math.min(255, smear * smear * 290 * row);
var j = (yy * sw + xx) * 4;
q[j] = q[j + 1] = q[j + 2] = v;
q[j + 3] = 255;
}
}
x.putImageData(id, 0, 0);
snow.push(c);
}
// The bars and the card are drawn once.
bars.width = canvas.width;
bars.height = canvas.height;
var b = bars.getContext('2d');
b.setTransform(dpr, 0, 0, dpr, 0, 0);
var bw = w / 7, i;
var h1 = h * 0.67, h2 = h * 0.08;
for (i = 0; i < 7; i++) {
b.fillStyle = TOP[i];
b.fillRect(Math.floor(i * bw), 0, Math.ceil(bw) + 1, h1);
b.fillStyle = MID[i];
b.fillRect(Math.floor(i * bw), h1, Math.ceil(bw) + 1, h2);
}
var bottom = [['#00214c', 1.25], ['#fff', 1.25], ['#32006a', 1.25], ['#131313', 1.25], ['#090909', 0.34], ['#131313', 0.33], ['#1d1d1d', 0.33], ['#131313', 1]];
var bx = 0;
bottom.forEach(function (s) {
b.fillStyle = s[0];
b.fillRect(Math.floor(bx), h1 + h2, Math.ceil(bw * s[1]) + 1, h - h1 - h2);
bx += bw * s[1];
});
// The card in the middle.
var fs = Math.max(13, Math.min(46, w * 0.065));
b.font = '800 ' + fs + 'px ui-monospace, Menlo, Consolas, monospace';
var tw = b.measureText(text).width;
var cw = tw + fs * 1.6, ch = fs * 2;
b.fillStyle = '#000';
b.fillRect((w - cw) / 2, (h - ch) / 2, cw, ch);
b.strokeStyle = '#fff';
b.lineWidth = Math.max(1.5, fs * 0.08);
b.strokeRect((w - cw) / 2 + fs * 0.2, (h - ch) / 2 + fs * 0.2, cw - fs * 0.4, ch - fs * 0.4);
b.fillStyle = '#fff';
b.textAlign = 'center';
b.textBaseline = 'middle';
b.fillText(text, w / 2, h / 2 + fs * 0.05);
return true;
}
function scanlines() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(0,0,0,.18)';
var step = Math.round(3 * dpr);
for (var y = 0; y < canvas.height; y += step) ctx.fillRect(0, y, canvas.width, Math.max(1, Math.round(dpr)));
// Rounded tube corners.
var g = ctx.createRadialGradient(canvas.width / 2, canvas.height / 2, Math.min(canvas.width, canvas.height) * 0.35, canvas.width / 2, canvas.height / 2, Math.hypot(canvas.width, canvas.height) * 0.56);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,.6)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function osd(blink) {
// The green channel read-out in the corner.
var fs = Math.max(11, Math.min(26, w * 0.04));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '700 ' + fs + 'px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillStyle = blink ? 'rgba(80,255,120,.95)' : 'rgba(80,255,120,.55)';
ctx.shadowColor = 'rgba(0,0,0,.8)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.fillText(channel, w - Math.max(10, w * 0.05), Math.max(8, h * 0.06));
ctx.shadowOffsetX = ctx.shadowOffsetY = 0;
}
var roll = 0;
function drawStatic(t) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.imageSmoothingEnabled = false;
var f = snow[(Math.random() * snow.length) | 0];
ctx.drawImage(f, -((Math.random() * 6) | 0), 0, canvas.width + 12, canvas.height);
ctx.imageSmoothingEnabled = true;
// A brighter band rolling down, and the odd torn line.
roll = (roll + 0.012) % 1.3;
var by = (roll - 0.15) * canvas.height;
var g = ctx.createLinearGradient(0, by - canvas.height * 0.15, 0, by + canvas.height * 0.15);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(0.5, 'rgba(255,255,255,.16)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, by - canvas.height * 0.15, canvas.width, canvas.height * 0.3);
if (Math.random() < 0.3) {
var ty = Math.random() * canvas.height, th = (2 + Math.random() * 8) * dpr;
ctx.drawImage(canvas, 0, ty, canvas.width, th, (Math.random() - 0.5) * 30 * dpr, ty, canvas.width, th);
}
scanlines();
osd(Math.floor(t / 400) % 2 === 0);
}
function drawBars(t, jolt) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Just after the cut the picture jumps a little before it settles.
var dy = jolt > 0 ? Math.round(Math.sin(jolt * 30) * jolt * canvas.height * 0.06) : 0;
ctx.drawImage(bars, 0, dy);
if (dy) ctx.drawImage(bars, 0, dy - canvas.height * Math.sign(dy));
scanlines();
// A faint flicker.
ctx.fillStyle = 'rgba(255,255,255,' + (Math.random() * 0.025) + ')';
ctx.fillRect(0, 0, canvas.width, canvas.height);
osd(true);
}
// The bright line of a tube switching over, between the static and the bars.
function drawCut(k) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
var lh = Math.max(2 * dpr, canvas.height * (1 - k) * 0.5);
ctx.fillStyle = '#fff';
ctx.shadowColor = '#bfe0ff';
ctx.shadowBlur = 20 * dpr;
ctx.fillRect(canvas.width * k * 0.3, (canvas.height - lh) / 2, canvas.width * (1 - k * 0.6), lh);
ctx.shadowBlur = 0;
}
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;
build();
}).observe(el);
}
if (reduced()) { drawBars(0, 0); return; }
drawStatic(0);
var CUT = 140;
var phase = 0, last = 0, acc = 0, total = 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;
phase += dt;
total += dt;
acc += dt;
// Around 30 frames a second is plenty for snow.
if (acc < 33) return;
acc = 0;
if (phase < staticMs) drawStatic(total);
else if (phase < staticMs + CUT) drawCut((phase - staticMs) / CUT);
else if (!loop || phase < staticMs + CUT + hold) drawBars(total, Math.max(0, 1 - (phase - staticMs - CUT) / 350));
else phase = 0;
}
});
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-no-signal-screen]').forEach(function (el) {
if (el._nss) return;
el._nss = true;
el.classList.add('nss');
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-no-signal-screen data-loop style="height:320px"></div>