← Filters & retro

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

  1. Paste the snippet just before </head>.
  2. Add data-no-signal-screen to 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-textThe words on the card. Default NO SIGNAL.
data-channelThe green read-out in the corner. Default AV 1.
data-staticHow long the static runs before the bars. Default 1800 ms.
data-loopGo back to static after a while and round again.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-no-signal-screen data-loop style="height:320px"></div>