A dot matrix sign drawn in SVG. The board is a grid of 57 by 11 round dots and a small built-in font lights the ones that spell the current message. When the message changes, each column switches a few milliseconds after the one before it, so the new word wipes across the board. A random handful of lit dots dim and recover every couple of seconds, like a real sign with tired bulbs, and a blurred copy underneath gives the amber glow. The demo sets it above a short venue listing. The SVG scales with its container, so on phones the sign simply gets smaller.
// Adapted from Animata (https://animata.design/docs/card/led-board)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useMemo, useState } from "react";
import { Clock, MapPin, Ticket } from "lucide-react";
// Each message can be up to nine characters: letters, digits, space and ! : - .
const messages = ["DOORS 7PM", "SOLD OUT", "ENCORE", "LAST CALL"];
const shows = [
{ day: "Thu 14", act: "The Paper Kites Trio", time: "8:00 pm", status: "Few left" },
{ day: "Fri 15", act: "Marlowe and the Night Shift", time: "9:30 pm", status: "Sold out" },
{ day: "Sat 16", act: "Open decks with Odile", time: "10:00 pm", status: "On sale" },
];
const COLS = 57;
const ROWS = 11;
// A 5 by 7 dot font. Each number is one row of five dots read as bits.
const FONT: Record<string, number[]> = {
A: [14, 17, 17, 31, 17, 17, 17], B: [30, 17, 17, 30, 17, 17, 30], C: [14, 17, 16, 16, 16, 17, 14],
D: [30, 17, 17, 17, 17, 17, 30], E: [31, 16, 16, 30, 16, 16, 31], F: [31, 16, 16, 30, 16, 16, 16],
G: [14, 17, 16, 23, 17, 17, 15], H: [17, 17, 17, 31, 17, 17, 17], I: [14, 4, 4, 4, 4, 4, 14],
J: [7, 2, 2, 2, 2, 18, 12], K: [17, 18, 20, 24, 20, 18, 17], L: [16, 16, 16, 16, 16, 16, 31],
M: [17, 27, 21, 21, 17, 17, 17], N: [17, 25, 21, 19, 17, 17, 17], O: [14, 17, 17, 17, 17, 17, 14],
P: [30, 17, 17, 30, 16, 16, 16], Q: [14, 17, 17, 17, 21, 18, 13], R: [30, 17, 17, 30, 20, 18, 17],
S: [15, 16, 16, 14, 1, 1, 30], T: [31, 4, 4, 4, 4, 4, 4], U: [17, 17, 17, 17, 17, 17, 14],
V: [17, 17, 17, 17, 17, 10, 4], W: [17, 17, 17, 21, 21, 27, 17], X: [17, 17, 10, 4, 10, 17, 17],
Y: [17, 17, 10, 4, 4, 4, 4], Z: [31, 1, 2, 4, 8, 16, 31],
"0": [14, 17, 19, 21, 25, 17, 14], "1": [4, 12, 4, 4, 4, 4, 14], "2": [14, 17, 1, 2, 4, 8, 31],
"3": [31, 2, 4, 2, 1, 17, 14], "4": [2, 6, 10, 18, 31, 2, 2], "5": [31, 16, 30, 1, 1, 17, 14],
"6": [6, 8, 16, 30, 17, 17, 14], "7": [31, 1, 2, 4, 8, 8, 8], "8": [14, 17, 17, 14, 17, 17, 14],
"9": [14, 17, 17, 15, 1, 2, 12],
" ": [0, 0, 0, 0, 0, 0, 0], "!": [4, 4, 4, 4, 4, 0, 4], ":": [0, 12, 12, 0, 12, 12, 0],
"-": [0, 0, 0, 31, 0, 0, 0], ".": [0, 0, 0, 0, 0, 12, 12],
};
/** Turns a word into a grid of lit and unlit dots, centred on the board. */
function createBoard(word: string): boolean[][] {
const grid = Array.from({ length: ROWS }, () => Array<boolean>(COLS).fill(false));
const chars = word.toUpperCase().split("").slice(0, 9);
const width = chars.length * 6 - 1;
let col = Math.max(0, Math.floor((COLS - width) / 2));
for (const char of chars) {
const glyph = FONT[char] ?? FONT[" "];
glyph.forEach((bits, row) => {
for (let x = 0; x < 5; x++) if (bits & (1 << (4 - x)) && col + x < COLS) grid[row + 2][col + x] = true;
});
col += 6;
}
return grid;
}
function seededRandom(row: number, col: number, salt: number) {
const x = Math.sin(row * 374761393 + col * 668265263 + salt * 1442695041) * 10000;
return x - Math.floor(x);
}
export default function LedDotMatrixSign() {
const [index, setIndex] = useState(0);
const [auto, setAuto] = useState(true);
const [twinkle, setTwinkle] = useState(0);
const grid = useMemo(() => createBoard(messages[index]), [index]);
// Cycle the message until the visitor picks one.
useEffect(() => {
if (!auto || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = setInterval(() => setIndex((i) => (i + 1) % messages.length), 3600);
return () => clearInterval(timer);
}, [auto]);
// Pick a new random handful of lit dots to flicker.
useEffect(() => {
const timer = setInterval(() => setTwinkle((t) => t + 1), 2400);
return () => clearInterval(timer);
}, []);
return (
<section className="bg-zinc-950 px-4 py-16 sm:px-6">
<style>{`
@keyframes led-dot-matrix-sign-flicker { 0%, 100% { opacity: 1 } 45% { opacity: 0.25 } 60% { opacity: 0.8 } }
.led-dot-matrix-sign-dot { transition: fill 220ms ease-in-out, opacity 220ms ease-in-out; }
.led-dot-matrix-sign-flicker { animation: led-dot-matrix-sign-flicker 1.8s ease-in-out both; }
@media (prefers-reduced-motion: reduce) {
.led-dot-matrix-sign-dot { transition: none; }
.led-dot-matrix-sign-flicker { animation: none; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<div className="relative rounded-2xl border border-zinc-800 bg-gradient-to-b from-zinc-900 to-black p-3 shadow-2xl shadow-amber-500/10 sm:p-5">
<div className="relative overflow-hidden rounded-lg bg-black p-2 ring-1 ring-inset ring-white/5 sm:p-4">
{/* Blurred copy of the lit dots, for the glow. */}
<svg aria-hidden="true" viewBox={`0 0 ${COLS} ${ROWS}`} className="pointer-events-none absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] blur-[6px] sm:inset-4 sm:h-[calc(100%-2rem)] sm:w-[calc(100%-2rem)]">
{grid.map((row, r) =>
row.map((lit, c) => (
<circle
key={`${r}-${c}`}
cx={c + 0.5}
cy={r + 0.5}
r={0.5}
className="led-dot-matrix-sign-dot"
style={{ fill: "#fb923c", opacity: lit ? 0.7 : 0, transitionDelay: `${c * 14}ms` }}
/>
)),
)}
</svg>
<svg role="img" aria-label={`Sign reading: ${messages[index]}`} viewBox={`0 0 ${COLS} ${ROWS}`} className="relative block h-auto w-full">
{grid.map((row, r) =>
row.map((lit, c) => {
const flicker = lit && seededRandom(r, c, twinkle) > 0.82;
return (
<circle
key={`${r}-${c}`}
cx={c + 0.5}
cy={r + 0.5}
r={0.34}
className={`led-dot-matrix-sign-dot ${flicker ? "led-dot-matrix-sign-flicker" : ""}`}
style={{
fill: lit ? "#fdba74" : "#27272a",
// The change runs across the board from left to right.
transitionDelay: `${c * 14}ms`,
animationDelay: flicker ? `${Math.floor(seededRandom(r, c, twinkle + 1) * 600)}ms` : undefined,
}}
/>
);
}),
)}
</svg>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 sm:mt-4">
<p className="text-xs font-medium uppercase tracking-widest text-zinc-400">The Lantern Room marquee</p>
<div role="group" aria-label="Sign message" className="flex flex-wrap gap-1.5">
{messages.map((message, i) => (
<button
key={message}
type="button"
aria-pressed={i === index}
onClick={() => {
setAuto(false);
setIndex(i);
}}
className={`h-10 rounded-md px-3 font-mono text-xs font-semibold tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${
i === index ? "bg-amber-400 text-zinc-950" : "bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
}`}
>
{message}
</button>
))}
</div>
</div>
</div>
<div className="mt-10 grid gap-8 sm:grid-cols-5">
<div className="sm:col-span-2">
<h2 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">Live music, four nights a week.</h2>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
A 180-capacity basement room under the old print works. The sign outside tells you everything you need to know.
</p>
<p className="mt-4 flex items-center gap-2 text-sm text-zinc-300">
<MapPin aria-hidden="true" className="h-4 w-4 text-amber-400" />
22 Foundry Lane, lower ground
</p>
</div>
<ul className="divide-y divide-zinc-800 border-y border-zinc-800 sm:col-span-3">
{shows.map((show) => (
<li key={show.day} className="flex items-center gap-4 py-3.5">
<span className="w-14 shrink-0 font-mono text-xs font-semibold uppercase text-amber-400">{show.day}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{show.act}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-zinc-400">
<Clock aria-hidden="true" className="h-3.5 w-3.5" />
{show.time}
</p>
</div>
<a
href="#"
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-full border border-zinc-700 px-3.5 text-xs font-semibold text-white transition-colors hover:border-amber-400 hover:text-amber-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
>
<Ticket aria-hidden="true" className="h-3.5 w-3.5" />
{show.status}
</a>
</li>
))}
</ul>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/led-dot-matrix-sign-with-sweeping-message-change.json
The built-in font has the letters A to Z, the digits 0 to 9, a space and four punctuation marks: exclamation mark, colon, hyphen and full stop. Lower case input is converted to capitals. Unknown characters show as a blank.
Every dot has a CSS transition on its fill colour, and its delay is its column number multiplied by 14 milliseconds. When the grid changes, all dots receive their new colour at once but each column starts its transition slightly later than the last.
No. The board has 627 circles plus a blurred copy, and only fill colour and opacity change. Browsers handle that easily. For a board several times larger, switch to a canvas.
Yes. Build a wider grid from a long message, keep an offset in state, raise it on a timer and render only the 57 columns that start at the offset. The dots themselves do not move, which is how real signs work.
Swap the three colour values. Red works well with `#f87171` lit and `#ef4444` glow; green with `#86efac` lit and `#22c55e` glow. Keep the unlit colour dark so the grid stays visible.
Adapted from Led Board in Animata, used under its open-source licence. Licences and credits
Related: Rolling Letter Departure Board Headline, Text Scramble Decode Headline, Live News Ticker Bar with Pulsing Dot, LED Dot Matrix Scrolling Message Board.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.