← Numbers

Dot-matrix Display

An LED board of round amber dots, like a station sign or a bus destination board. Each message scrolls in from the right a column at a time: short ones stop in the middle and light up, long ones scroll straight through. Lit dots fade with a slight afterglow, just like real LEDs.

NEXT TRAIN 3 MIN
£4.20

How to use it

  1. Paste the snippet just before </head>.
  2. Put the message in an element with data-dot-matrix-display and give it a height. The board fills the element; the dots grow with its height and as many columns fit as its width allows.

Options

Add any of these attributes to the same element.

data-values="A|B|C"Several messages shown in turn.
data-speedScroll speed, in columns per second. Default 22.
data-holdHow long a short message rests in the middle. Default 1800 ms.
data-colorLED colour. Default #ffb02e; try red #ff3b30 or green #4ade80.
data-boardBoard colour. Default #120d0a.

The board is seven dots tall and shows capitals. It knows A–Z, 0–9 and . , : ; ! ? - + = % $ £ € # & ' " / ( ) < > * ° ♥ ↑ ↓. Screen readers hear each message once.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dot-matrix Display: an LED board of round dots. Each message scrolls in
    // from the right a column at a time; short ones stop in the middle and
    // light up, long ones scroll straight through. Lit dots fade off with a
    // slight afterglow, like real LEDs.
    // Usage: <div data-dot-matrix-display>NEXT TRAIN 3 MIN</div>
    // Optional: data-values (several messages shown in turn, "A|B|C"),
    // data-speed (columns per second, default 22), data-hold (ms a short
    // message rests in the middle, default 1800), data-color (LED colour,
    // default #ffb02e), data-board (board colour, default #120d0a).
    // The board is 7 dots tall; give the element a height (default 72px) and
    // the dots grow to fill it, with as many columns as fit the width.
    // Letters show as capitals. It knows A-Z, 0-9, space and . , : ; ! ? - + = % $ £ € # & ' " / ( ) < > * ° ♥ ↑ ↓.
    var DEFAULTS = { speed: 22, hold: 1800, color: '#ffb02e', board: '#120d0a' };

    var css =
      ':where(.dmx){height:72px}' +
      '.dmx{display:block;position:relative;border-radius:10px;overflow:hidden;' +
      'box-shadow:inset 0 0 0 2px rgba(255,255,255,.06),inset 0 10px 24px rgba(0,0,0,.5)}' +
      '.dmx canvas{position:absolute;inset:0;width:100%;height:100%;display:block}' +
      '.dmx-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // 5 × 7 font: seven rows per glyph, two hex digits per row, bit 4 = left column.
    var FONT = {
      '0': '0E11131519110E', '1': '040C040404040E', '2': '0E11010204081F', '3': '1F02040201110E',
      '4': '02060A121F0202', '5': '1F101E0101110E', '6': '0608101E11110E', '7': '1F010204080808',
      '8': '0E11110E11110E', '9': '0E11110F01020C',
      A: '0E1111111F1111', B: '1E11111E11111E', C: '0E11101010110E', D: '1C12111111121C',
      E: '1F10101E10101F', F: '1F10101E101010', G: '0E11101711110F', H: '1111111F111111',
      I: '0E04040404040E', J: '0702020202120C', K: '11121418141211', L: '1010101010101F',
      M: '111B1515111111', N: '11111915131111', O: '0E11111111110E', P: '1E11111E101010',
      Q: '0E11111115120D', R: '1E11111E141211', S: '0F10100E01011E', T: '1F040404040404',
      U: '1111111111110E', V: '11111111110A04', W: '1111111515150A', X: '11110A040A1111',
      Y: '1111110A040404', Z: '1F01020408101F',
      ' ': '00000000000000', '.': '00000000000C0C', ',': '00000000000C04', ':': '000C0C000C0C00',
      ';': '000C0C000C0408', '!': '04040404040004', '?': '0E110102040004', '-': '0000001F000000',
      '+': '0004041F040400', '=': '00001F001F0000', '%': '18190204081303', '$': '040F140E051E04',
      '£': '0609081C08081F', '€': '07081E081E0807', '#': '0A0A1F0A1F0A0A', '&': '0C12140815120D',
      "'": '0C040800000000', '"': '0A0A0000000000', '/': '00010204081000', '(': '02040808080402',
      ')': '08040202020408', '<': '02040810080402', '>': '08040201020408', '*': '0004150E150400',
      '°': '0C12120C000000', '♥': '000A1F1F0E0400', '↑': '040E1504040404', '↓': '04040404150E04'
    };
    // Turn a glyph into columns (7-bit masks, bit 0 = top row). Digits keep all
    // five columns so numbers line up; everything else is trimmed to its ink.
    function glyph(ch) {
      var hex = (FONT[ch] || FONT['?']).replace(/\s/g, ''), rows = [], cols = [];
      for (var r = 0; r < 7; r++) rows.push(parseInt(hex.substr(r * 2, 2), 16));
      for (var c = 0; c < 5; c++) {
        var m = 0;
        for (var y = 0; y < 7; y++) if (rows[y] & (16 >> c)) m |= 1 << y;
        cols.push(m);
      }
      if (ch === ' ') return [0, 0, 0];
      if (/\d/.test(ch)) return cols;
      while (cols.length > 1 && !cols[0]) cols.shift();
      while (cols.length > 1 && !cols[cols.length - 1]) cols.pop();
      return cols;
    }
    function render(text) {
      var out = [], chars = Array.from(text.toUpperCase());
      chars.forEach(function (ch, i) {
        out = out.concat(glyph(ch));
        if (i < chars.length - 1) out.push(0);
      });
      return out;
    }

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgb(hex) {
      var h = hex.replace('#', '');
      if (h.length === 3) h = h.replace(/./g, '$&$&');
      var n = parseInt(h, 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }

    function setup(el) {
      var d = el.dataset;
      var text = el.textContent.trim();
      var b = {
        el: el, values: (d.values ? d.values.split('|') : [text || 'HELLO']),
        speed: +d.speed || DEFAULTS.speed, hold: d.hold !== undefined ? +d.hold : DEFAULTS.hold,
        color: rgb(d.color || DEFAULTS.color), still: reduced(),
        index: 0, cols: 0, glow: [], lit: [], visible: false, raf: 0, last: 0
      };
      el.classList.add('dmx');
      el.style.background = d.board || DEFAULTS.board;
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'dmx-sr';
      sr.setAttribute('aria-live', 'polite');
      el.appendChild(sr);
      b.sr = sr;
      b.canvas = document.createElement('canvas');
      b.canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(b.canvas);
      b.ctx = b.canvas.getContext('2d');
      resize(b);
      new ResizeObserver(function () {
        resize(b);
        if (b.still) { load(b, b.index); b.glow = b.lit.slice(); }
        if (!b.raf) draw(b);
      }).observe(el);
      load(b, 0);
      return b;
    }

    function resize(b) {
      var w = b.el.clientWidth, h = b.el.clientHeight || 72, dpr = Math.min(2, window.devicePixelRatio || 1);
      b.canvas.width = Math.round(w * dpr);
      b.canvas.height = Math.round(h * dpr);
      b.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      b.w = w; b.h = h;
      b.pitch = h / 9;                                  // 7 rows plus a dot of margin top and bottom
      var cols = Math.max(8, Math.floor((w - b.pitch) / b.pitch));
      if (cols !== b.cols) {
        b.cols = cols;
        b.glow = new Array(cols * 7).fill(0);
        b.lit = new Array(cols * 7).fill(0);
        if (b.msg) { b.fits = b.msg.length <= cols; place(b); }
      }
      b.ox = (w - cols * b.pitch) / 2;
      b.oy = b.pitch;
    }

    // Load message i: it starts just off the right edge.
    function load(b, i) {
      b.index = i;
      var text = b.values[i];
      b.msg = render(text);
      b.sr.textContent = text;
      b.el.setAttribute('aria-label', text);
      b.fits = b.msg.length <= b.cols;
      b.pos = b.cols;                                   // column where the message starts
      b.wait = 0;
      b.stopped = false;
      if (b.still) b.pos = b.fits ? Math.floor((b.cols - b.msg.length) / 2) : 1;
      place(b);
    }

    function place(b) {
      var cols = b.cols, msg = b.msg, p = Math.round(b.pos);
      for (var c = 0; c < cols; c++) {
        var m = msg[c - p] || 0;
        for (var y = 0; y < 7; y++) b.lit[y * cols + c] = (m >> y) & 1;
      }
    }

    function advance(b, dt) {
      if (b.wait > 0) {
        b.wait -= dt;
        if (b.wait <= 0 && b.leaving) load(b, (b.index + 1) % b.values.length);
        return;
      }
      var centre = Math.floor((b.cols - b.msg.length) / 2);
      var before = Math.round(b.pos);
      b.pos -= b.speed * dt / 1000;
      if (b.fits && !b.stopped && b.pos <= centre) {
        b.pos = centre;
        b.stopped = true;
        b.wait = b.hold;
        b.flash = 1;                                    // a little brightening as it lands
      }
      if (b.pos < -b.msg.length) {
        b.leaving = true;
        b.wait = 350;
        b.pos = -b.msg.length;
        return;
      }
      b.leaving = false;
      if (Math.round(b.pos) !== before) place(b);
    }

    function draw(b) {
      var ctx = b.ctx, cols = b.cols, p = b.pitch, r = p * 0.36;
      var c = b.color, base = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
      var hot = 'rgb(' + Math.min(255, c[0] + 90) + ',' + Math.min(255, c[1] + 90) + ',' + Math.min(255, c[2] + 90) + ')';
      ctx.clearRect(0, 0, b.w, b.h);
      var boost = 1 + (b.flash || 0) * 0.5;
      for (var y = 0; y < 7; y++) {
        for (var x = 0; x < cols; x++) {
          var g = b.glow[y * cols + x];
          var cx = b.ox + (x + 0.5) * p, cy = b.oy + (y + 0.5) * p;
          ctx.globalAlpha = 0.09;
          ctx.fillStyle = base;
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, 6.2832);
          ctx.fill();
          if (g < 0.02) continue;
          ctx.globalAlpha = Math.min(1, 0.16 * g * boost);
          ctx.beginPath();
          ctx.arc(cx, cy, r * 1.4, 0, 6.2832);
          ctx.fill();
          ctx.globalAlpha = g;
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, 6.2832);
          ctx.fill();
          ctx.globalAlpha = g * 0.85;
          ctx.fillStyle = hot;
          ctx.beginPath();
          ctx.arc(cx - r * 0.15, cy - r * 0.15, r * 0.45, 0, 6.2832);
          ctx.fill();
        }
      }
      ctx.globalAlpha = 1;
    }

    function frame(b, now) {
      var dt = Math.min(64, now - (b.last || now));
      b.last = now;
      advance(b, dt);
      // LEDs switch on fast and fade off with a short afterglow.
      for (var i = 0; i < b.lit.length; i++) {
        var t = b.lit[i], g = b.glow[i];
        b.glow[i] = t ? Math.min(1, g + dt / 16) : g * Math.pow(0.5, dt / 14);
      }
      if (b.flash) b.flash = Math.max(0, b.flash - dt / 500);
      draw(b);
      b.raf = requestAnimationFrame(function (n) { frame(b, n); });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var b = e.target._dmx;
          b.visible = e.isIntersecting;
          cancelAnimationFrame(b.raf);
          b.raf = 0;
          if (b.visible) { b.last = 0; b.raf = requestAnimationFrame(function (n) { frame(b, n); }); }
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-dot-matrix-display]').forEach(function (el) {
        if (el._dmx) return;
        var b = (el._dmx = setup(el));
        if (b.still) {
          b.glow = b.lit.slice();
          draw(b);
          return;
        }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-dot-matrix-display style="height: 72px">NEXT TRAIN 3 MIN</div>

<!-- several messages, in red: -->
<div data-dot-matrix-display data-values="PLATFORM 4|09:45|ON TIME"
     data-color="#ff3b30" style="height: 96px"></div>