LED Dot Matrix Scrolling Message Board

A venue style sign built from rows of small round lights. A message is turned into a dot pattern with a five row font, then scrolled across a canvas one column at a time, with a soft glow around each lit light. A large board carries the headline message and a thinner one below carries the small print. Visitors can pause the scroll, switch the light colour and drag across the board to switch lights on by hand. On phones the boards simply show fewer columns, so nothing is squeezed or cut off.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/lightboard)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
import { Pause, Play } from "lucide-react";

/** The two messages. Letters, digits and . : - ! * are supported. */
const boards = {
  main: "HALDEN HALL * TONIGHT * THE NIGHT LINES * DOORS 7:30 * FEW TICKETS LEFT *",
  small: "BAR OPEN TILL 1:00 - COAT CHECK ON LEVEL 2 - NEXT WEEK: MARA OKAFOR TRIO -",
};

const palettes = [
  { name: "Amber", rgb: "251,191,36", swatch: "bg-amber-400" },
  { name: "Cyan", rgb: "34,211,238", swatch: "bg-cyan-400" },
  { name: "Rose", rgb: "251,113,133", swatch: "bg-rose-400" },
  { name: "Lime", rgb: "163,230,53", swatch: "bg-lime-400" },
];

/** Five-row dot font. Each string is one row, 1 = lit. */
const FONT: Record<string, string[]> = {
  A: ["0110", "1001", "1111", "1001", "1001"],
  B: ["1110", "1001", "1110", "1001", "1110"],
  C: ["0111", "1000", "1000", "1000", "0111"],
  D: ["1110", "1001", "1001", "1001", "1110"],
  E: ["1111", "1000", "1110", "1000", "1111"],
  F: ["1111", "1000", "1110", "1000", "1000"],
  G: ["0111", "1000", "1011", "1001", "0111"],
  H: ["1001", "1001", "1111", "1001", "1001"],
  I: ["111", "010", "010", "010", "111"],
  J: ["0011", "0001", "0001", "1001", "0110"],
  K: ["1001", "1010", "1100", "1010", "1001"],
  L: ["1000", "1000", "1000", "1000", "1111"],
  M: ["10001", "11011", "10101", "10001", "10001"],
  N: ["1001", "1101", "1011", "1001", "1001"],
  O: ["0110", "1001", "1001", "1001", "0110"],
  P: ["1110", "1001", "1110", "1000", "1000"],
  Q: ["0110", "1001", "1001", "1010", "0101"],
  R: ["1110", "1001", "1110", "1010", "1001"],
  S: ["0111", "1000", "0110", "0001", "1110"],
  T: ["11111", "00100", "00100", "00100", "00100"],
  U: ["1001", "1001", "1001", "1001", "0110"],
  V: ["10001", "10001", "01010", "01010", "00100"],
  W: ["10001", "10001", "10101", "11011", "10001"],
  X: ["1001", "1001", "0110", "1001", "1001"],
  Y: ["10001", "01010", "00100", "00100", "00100"],
  Z: ["1111", "0001", "0110", "1000", "1111"],
  "0": ["0110", "1011", "1101", "1001", "0110"],
  "1": ["010", "110", "010", "010", "111"],
  "2": ["1110", "0001", "0110", "1000", "1111"],
  "3": ["1110", "0001", "0110", "0001", "1110"],
  "4": ["1001", "1001", "1111", "0001", "0001"],
  "5": ["1111", "1000", "1110", "0001", "1110"],
  "6": ["0110", "1000", "1110", "1001", "0110"],
  "7": ["1111", "0001", "0010", "0100", "0100"],
  "8": ["0110", "1001", "0110", "1001", "0110"],
  "9": ["0110", "1001", "0111", "0001", "0110"],
  ":": ["0", "1", "0", "1", "0"],
  ".": ["0", "0", "0", "0", "1"],
  "!": ["1", "1", "1", "0", "1"],
  "-": ["000", "000", "111", "000", "000"],
  "*": ["000", "010", "111", "010", "000"],
  " ": ["00", "00", "00", "00", "00"],
};

const ROWS = 7; // five rows of letter with one empty row above and below

/** Turns a message into ROWS strings of 0 and 1, one character per light. */
function textToPattern(text: string): Uint8Array[] {
  const rows: number[][] = Array.from({ length: ROWS }, () => []);
  for (const ch of ` ${text.toUpperCase()} `) {
    const glyph = FONT[ch] ?? FONT[" "];
    for (let r = 0; r < ROWS; r++) {
      const line = r === 0 || r === ROWS - 1 ? "0".repeat(glyph[0].length) : glyph[r - 1];
      for (const c of line) rows[r].push(c === "1" ? 1 : 0);
      rows[r].push(0);
    }
  }
  return rows.map((r) => Uint8Array.from(r));
}

function Board({ text, rgb, dot, gap, speed, paused, label }: { text: string; rgb: string; dot: number; gap: number; speed: number; paused: boolean; label: string }) {
  const reduce = useReducedMotion();
  const wrap = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const pausedRef = useRef(paused);
  pausedRef.current = paused || !!reduce;
  const rgbRef = useRef(rgb);
  rgbRef.current = rgb;

  useEffect(() => {
    const el = wrap.current;
    const cv = canvas.current;
    const ctx = cv?.getContext("2d");
    if (!el || !cv || !ctx) return;

    const pattern = textToPattern(text);
    const width = pattern[0].length;
    const step = dot + gap;
    let columns = 0;
    let offset = 0;
    let last = 0;
    let frame = 0;
    let visible = true;
    let drawing = false;

    const size = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      columns = Math.floor(el.clientWidth / step);
      cv.width = columns * step * dpr;
      cv.height = ROWS * step * dpr;
      cv.style.width = `${columns * step}px`;
      cv.style.height = `${ROWS * step}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      paint();
    };

    const paint = () => {
      const start = Math.floor(offset);
      const color = rgbRef.current;
      ctx.clearRect(0, 0, columns * step, ROWS * step);
      for (let pass = 0; pass < 3; pass++) {
        // pass 0: unlit lights, pass 1: soft glow, pass 2: lit cores
        ctx.fillStyle = pass === 0 ? "rgba(148,163,184,0.13)" : pass === 1 ? `rgba(${color},0.16)` : `rgba(${color},0.96)`;
        ctx.beginPath();
        for (let r = 0; r < ROWS; r++) {
          for (let c = 0; c < columns; c++) {
            const lit = pattern[r][(c + start) % width] === 1;
            if (lit === (pass === 0)) continue;
            const x = c * step + dot / 2;
            const y = r * step + dot / 2;
            ctx.moveTo(x + dot, y);
            ctx.arc(x, y, pass === 1 ? dot * 0.95 : dot / 2, 0, Math.PI * 2);
          }
        }
        ctx.fill();
      }
    };

    const loop = (now: number) => {
      const dt = Math.min(now - (last || now), 80);
      last = now;
      if (!pausedRef.current && !drawing) offset = (offset + (dt / 1000) * speed) % width;
      paint();
      frame = visible && !document.hidden ? requestAnimationFrame(loop) : 0;
    };
    const start = () => {
      if (!frame && visible && !document.hidden) {
        last = 0;
        frame = requestAnimationFrame(loop);
      }
    };

    // Drag across the board to switch lights on. They scroll away with the message.
    const light = (e: PointerEvent) => {
      const box = cv.getBoundingClientRect();
      const c = Math.floor((e.clientX - box.left) / step);
      const r = Math.floor((e.clientY - box.top) / step);
      if (r < 0 || r >= ROWS || c < 0 || c >= columns) return;
      pattern[r][(c + Math.floor(offset)) % width] = 1;
      paint();
    };
    const down = (e: PointerEvent) => {
      drawing = true;
      light(e);
    };
    const move = (e: PointerEvent) => drawing && light(e);
    const up = () => {
      drawing = false;
    };

    const ro = new ResizeObserver(size);
    ro.observe(el);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      start();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", start);
    cv.addEventListener("pointerdown", down);
    cv.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    size();
    start();
    return () => {
      cancelAnimationFrame(frame);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", start);
      cv.removeEventListener("pointerdown", down);
      cv.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
  }, [text, dot, gap, speed]);

  return (
    <div ref={wrap} className="flex w-full justify-center overflow-hidden">
      <canvas ref={canvas} role="img" aria-label={label} className="block cursor-crosshair" style={{ touchAction: "pan-y", height: ROWS * (dot + gap) }} />
    </div>
  );
}

export default function LedDotMatrixScrollingMessageBoard() {
  const [paused, setPaused] = useState(false);
  const [palette, setPalette] = useState(0);
  const color = palettes[palette];

  return (
    <section className="relative overflow-hidden bg-slate-950 px-4 py-14 text-slate-200 sm:px-6 sm:py-20">
      <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-72" style={{ background: `radial-gradient(60% 100% at 50% 0%, rgba(${color.rgb},0.16), transparent 70%)` }} />
      <div className="relative mx-auto max-w-5xl">
        <div className="flex flex-wrap items-end justify-between gap-6">
          <div className="max-w-xl">
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Halden Hall box office</p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">The sign above the door, on your page.</h2>
            <p className="mt-3 text-base text-slate-400">A scrolling board made of single lights. Drag across it to switch a few on yourself.</p>
          </div>
          <div className="flex items-center gap-3">
            <div role="radiogroup" aria-label="Light colour" className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 p-1">
              {palettes.map((p, i) => (
                <button
                  key={p.name}
                  type="button"
                  role="radio"
                  aria-checked={i === palette}
                  aria-label={p.name}
                  onClick={() => setPalette(i)}
                  className={`grid h-10 w-10 place-items-center rounded-full outline-none transition focus-visible:ring-2 focus-visible:ring-white ${i === palette ? "bg-white/10" : "hover:bg-white/5"}`}
                >
                  <span className={`h-4 w-4 rounded-full ${p.swatch} ${i === palette ? "ring-2 ring-white/80 ring-offset-2 ring-offset-slate-900" : ""}`} />
                </button>
              ))}
            </div>
            <button
              type="button"
              aria-pressed={paused}
              onClick={() => setPaused((v) => !v)}
              className="inline-flex h-12 items-center gap-2 rounded-full border border-white/10 bg-white/5 px-5 text-sm font-medium text-white outline-none transition hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white"
            >
              {paused ? <Play className="h-4 w-4" aria-hidden="true" /> : <Pause className="h-4 w-4" aria-hidden="true" />}
              {paused ? "Play" : "Pause"}
            </button>
          </div>
        </div>

        <div className="mt-10 rounded-3xl border border-white/10 bg-gradient-to-b from-slate-900 to-black p-3 shadow-2xl shadow-black/60 sm:p-4">
          <div className="rounded-2xl border border-white/5 bg-black px-3 py-5 sm:px-5 sm:py-7">
            <Board text={boards.main} rgb={color.rgb} dot={8} gap={3} speed={16} paused={paused} label={boards.main} />
          </div>
          <div className="mt-3 grid gap-3 sm:grid-cols-[1fr_auto]">
            <div className="rounded-2xl border border-white/5 bg-black px-3 py-3 sm:px-5">
              <Board text={boards.small} rgb="226,232,240" dot={3} gap={2} speed={22} paused={paused} label={boards.small} />
            </div>
            <a href="#" className="inline-flex h-12 items-center justify-center whitespace-nowrap rounded-2xl bg-white px-6 text-sm font-semibold text-slate-950 outline-none transition hover:bg-slate-200 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:h-auto">
              Get tickets
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/led-dot-matrix-scrolling-message-board.json

When to use it

How to customize it

Accessibility

Questions

Why canvas instead of one element per light?

A wide board has more than a thousand lights. Drawing them as circles on one canvas keeps each frame cheap, while a thousand elements would make the page slow to lay out and repaint.

How do I add a character that is missing?

Add a key to `FONT` with five strings, one per row, using 1 for a lit light and 0 for an unlit one. Rows can be any width as long as all five match. Unknown characters are shown as a space.

Can I stop visitors drawing on it?

Yes. Remove the three pointer listeners near the end of the effect in `Board`, and the `cursor-crosshair` class on the canvas.

Does it scroll smoothly or in steps?

In steps of one column, the way a real dot sign moves. The position is tracked as a fraction and rounded down when painting, so the speed stays the same on fast and slow screens.

How does it fit different widths?

A `ResizeObserver` counts how many columns fit in the container and resizes the canvas to match. The message wraps around, so any width shows a continuous loop.

Adapted from LightBoard in Cult UI, used under its open-source licence. Licences and credits

Related: Live News Ticker Bar with Pulsing Dot, Rolling Letter Departure Board Headline, Flickering Pixel Grid Canvas Background.

More marquees or all free UI components. Start for free to have a full site built around it.

More in Marquees

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.

Product

Who It's For

Features

Resources

Company

View Sitemap