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
- Paste the snippet just before
</head>. - Put the message in an element with
data-dot-matrix-displayand 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-speed | Scroll speed, in columns per second. Default 22. |
data-hold | How long a short message rests in the middle. Default 1800 ms. |
data-color | LED colour. Default #ffb02e; try red #ff3b30 or green #4ade80. |
data-board | Board 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.
<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><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>