Matrix

An LED dot matrix display component with SVG pixels, radial glow, animated presets, and a VU meter mode.

Installation

File Structure

matrix.tsx

Usage

import {
  Matrix,
  digits,
  loader,
  wave,
  snake,
  pulse,
  chevronLeft,
  chevronRight,
  vu,
  emptyFrame,
  setPixel,
} from "@/components/unlumen-ui/matrix"

// Static pattern — render digit "7"
<Matrix rows={7} cols={5} pattern={digits[7]} size={12} gap={3} />

// Animated preset
<Matrix rows={7} cols={7} frames={loader} fps={12} size={12} gap={3} />

// VU meter mode
<Matrix
  rows={7}
  cols={8}
  mode="vu"
  levels={[0.9, 0.6, 0.8, 0.4, 0.7, 0.5, 0.8, 0.3]}
  size={12}
  gap={3}
/>

API Reference

Matrix

rows
number

Number of pixel rows.

cols
number

Number of pixel columns.

pattern?
Frame

A static 2D array (Frame) to display. Disables animation when set.

frames?
Frame[]

An array of Frames to animate through.

fps?
number
12

Animation frame rate.

autoplay?
boolean
true

Whether to start animation automatically.

loop?
boolean
true

Whether to loop the animation.

size?
number
10

Pixel diameter in pixels.

gap?
number
2

Gap between pixels in pixels.

palette?
{ on: string; off: string }
{ on: "currentColor", off: "var(--muted-foreground)" }

Color overrides for on/off pixels. Accepts any CSS color value.

brightness?
number
1

Global brightness multiplier applied to all pixels (0–1).

mode?
"default" | "vu"
"default"

Display mode. `"vu"` renders a vertical bar graph from the `levels` prop.

levels?
number[]

Per-column level values (0–1) used when `mode="vu"`. Length should match `cols`.

onFrame?
(index: number) => void

Callback fired on each frame advance, receives frame index.

ariaLabel?
string
"matrix display"

Accessible label for the matrix display.

className?
string

Additional className applied to the wrapper div.

Types

// A 2D grid of brightness values (0 = off, 1 = fully on, 0–1 for dimmed)
type Frame = number[][];

Built-in patterns & animations

ExportTypeDescription
digitsFrame[]Ten digit glyphs (0–9), each 7×5.
chevronLeftFrameLeft-pointing chevron, 5×5.
chevronRightFrameRight-pointing chevron, 5×5.
loaderFrame[]Rotating arc loader, 12 frames, 7×7.
pulseFrame[]Expanding ring pulse, 16 frames, 7×7.
waveFrame[]Sinusoidal wave, 24 frames, 7×7.
snakeFrame[]Spiral-filling snake, 49 frames, 7×7.

Utility functions

// Generate a VU meter Frame from an array of levels
vu(columns: number, levels: number[]): Frame

// Create a blank Frame filled with zeros
emptyFrame(rows: number, cols: number): Frame

// Write a brightness value to a single cell (mutates in-place)
setPixel(frame: Frame, row: number, col: number, value: number): void

Image to Matrix

Convert any image into a binary Frame (0s and 1s) you can paste directly into your code. Upload an image, tune the resolution and threshold, then copy the result.

demo-matrix-image-to-frame.tsx
"use client";

import { useCallback, useRef, useState } from "react";

import { cn } from "@/lib/utils";
import { UnlumenSlider } from "@/components/ui/unlumen-slider";
import { type Frame, Matrix } from "@/components/unlumen-ui/matrix";

const DEFAULT_ROWS = 16;
const DEFAULT_COLS = 16;
const MIN_SIZE = 4;
const MAX_SIZE = 32;

function imageToFrame(
  image: HTMLImageElement,
  rows: number,
  cols: number,
  threshold: number,
  invert: boolean,
): Frame {
  const canvas = document.createElement("canvas");
  canvas.width = cols;
  canvas.height = rows;
  const ctx = canvas.getContext("2d")!;
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";
  ctx.drawImage(image, 0, 0, cols, rows);
  const { data } = ctx.getImageData(0, 0, cols, rows);
  const frame: Frame = [];
  for (let r = 0; r < rows; r++) {
    const row: number[] = [];
    for (let c = 0; c < cols; c++) {
      const i = (r * cols + c) * 4;
      const luma =
        (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255;
      const alpha = data[i + 3] / 255;
      const value = invert ? luma : 1 - luma;
      row.push(value * alpha >= threshold ? 1 : 0);
    }
    frame.push(row);
  }
  return frame;
}

function frameToCode(frame: Frame): string {
  const inner = frame.map((row) => `  [${row.join(", ")}]`).join(",\n");
  return `const frame: Frame = [\n${inner},\n];`;
}

function loadImageElement(file: File): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      resolve(img);
    };
    img.onerror = () => {
      URL.revokeObjectURL(url);
      reject(new Error("Failed to load image"));
    };
    img.src = url;
  });
}

export default function MatrixImageToFrameDemo() {
  const [frame, setFrame] = useState<Frame | null>(null);
  const [rows, setRows] = useState(DEFAULT_ROWS);
  const [cols, setCols] = useState(DEFAULT_COLS);
  const [threshold, setThreshold] = useState(0.5);
  const [invert, setInvert] = useState(false);
  const [isDragging, setIsDragging] = useState(false);
  const [copied, setCopied] = useState(false);

  const imageRef = useRef<HTMLImageElement | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  const recompute = useCallback(
    (img: HTMLImageElement, r: number, c: number, t: number, inv: boolean) => {
      setFrame(imageToFrame(img, r, c, t, inv));
    },
    [],
  );

  const handleFile = useCallback(
    async (file: File) => {
      if (!file.type.startsWith("image/")) return;
      const img = await loadImageElement(file);
      imageRef.current = img;
      recompute(img, rows, cols, threshold, invert);
    },
    [rows, cols, threshold, invert, recompute],
  );

  const handleRows = useCallback(
    (v: number) => {
      setRows(v);
      if (imageRef.current)
        recompute(imageRef.current, v, cols, threshold, invert);
    },
    [cols, threshold, invert, recompute],
  );

  const handleCols = useCallback(
    (v: number) => {
      setCols(v);
      if (imageRef.current)
        recompute(imageRef.current, rows, v, threshold, invert);
    },
    [rows, threshold, invert, recompute],
  );

  const handleThreshold = useCallback(
    (v: number) => {
      setThreshold(v);
      if (imageRef.current) recompute(imageRef.current, rows, cols, v, invert);
    },
    [rows, cols, invert, recompute],
  );

  const handleInvert = useCallback(() => {
    const next = !invert;
    setInvert(next);
    if (imageRef.current)
      recompute(imageRef.current, rows, cols, threshold, next);
  }, [invert, rows, cols, threshold, recompute]);

  const handleCopy = useCallback(() => {
    if (!frame) return;
    navigator.clipboard.writeText(frameToCode(frame));
    setCopied(true);
    setTimeout(() => setCopied(false), 1800);
  }, [frame]);

  const pixelSize = Math.max(2, Math.floor(320 / Math.max(rows, cols)) - 1);

  return (
    <div className="flex flex-col items-center gap-6 p-8 min-h-[420px]">
      {!frame ? (
        <button
          onClick={() => inputRef.current?.click()}
          onDragOver={(e) => {
            e.preventDefault();
            setIsDragging(true);
          }}
          onDragLeave={() => setIsDragging(false)}
          onDrop={(e) => {
            e.preventDefault();
            setIsDragging(false);
            const file = e.dataTransfer.files[0];
            if (file) handleFile(file);
          }}
          className={cn(
            "group flex flex-col items-center justify-center gap-3",
            "w-64 h-48 rounded-xl border-2 border-dashed",
            "transition-all duration-200 cursor-pointer",
            isDragging
              ? "border-foreground/40 bg-foreground/5"
              : "border-border hover:border-foreground/30 hover:bg-muted/30",
          )}
        >
          <svg
            width="28"
            height="28"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            className={cn(
              "transition-colors duration-200",
              isDragging
                ? "text-foreground/60"
                : "text-muted-foreground group-hover:text-foreground/50",
            )}
          >
            <rect x="3" y="3" width="18" height="18" rx="2" />
            <circle cx="9" cy="9" r="2" />
            <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
          </svg>
          <span className="text-xs text-muted-foreground font-mono tracking-wide">
            {isDragging ? "drop image" : "upload image"}
          </span>
        </button>
      ) : (
        <div className="flex flex-col items-center gap-5 w-full max-w-sm">
          <Matrix
            rows={rows}
            cols={cols}
            pattern={frame}
            size={pixelSize}
            gap={1}
            ariaLabel="image to matrix preview"
          />

          <div className="flex flex-col gap-2.5 w-full">
            <div className="flex gap-4">
              <UnlumenSlider
                value={rows}
                onChange={(v) => handleRows(v as number)}
                min={MIN_SIZE}
                max={MAX_SIZE}
                step={1}
                label="rows"
                valuePosition="right"
                className="flex-1"
              />
              <UnlumenSlider
                value={cols}
                onChange={(v) => handleCols(v as number)}
                min={MIN_SIZE}
                max={MAX_SIZE}
                step={1}
                label="cols"
                valuePosition="right"
                className="flex-1"
              />
            </div>

            <UnlumenSlider
              value={threshold}
              onChange={(v) => handleThreshold(v as number)}
              min={0.1}
              max={0.9}
              step={0.01}
              label="threshold"
              valuePosition="right"
              formatValue={(v) => v.toFixed(2)}
            />

            <div className="flex items-center justify-between">
              <div className="flex items-center gap-3">
                <button
                  onClick={handleInvert}
                  className={cn(
                    "flex items-center gap-1.5 text-[10px] font-mono tracking-widest uppercase transition-colors",
                    invert
                      ? "text-foreground"
                      : "text-muted-foreground hover:text-foreground/60",
                  )}
                >
                  <span
                    className={cn(
                      "inline-block w-3 h-3 rounded-full border transition-all",
                      invert
                        ? "bg-foreground border-foreground"
                        : "bg-transparent border-muted-foreground",
                    )}
                  />
                  invert
                </button>
                <button
                  onClick={() => inputRef.current?.click()}
                  className="text-[10px] font-mono tracking-widest uppercase text-muted-foreground hover:text-foreground transition-colors"
                >
                  change
                </button>
              </div>
              <button
                onClick={handleCopy}
                className={cn(
                  "text-[10px] font-mono tracking-widest uppercase transition-colors px-2.5 py-1 rounded border",
                  copied
                    ? "border-foreground/30 text-foreground bg-foreground/5"
                    : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30",
                )}
              >
                {copied ? "copied!" : "copy frame"}
              </button>
            </div>
          </div>
        </div>
      )}

      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        className="hidden"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) handleFile(file);
          e.target.value = "";
        }}
      />
    </div>
  );
}

Notes

Frames are plain 2D arrays — you can compose custom animations by building Frame[] arrays with emptyFrame and setPixel, or by hand-coding the grid literal (as the built-in digits are authored).

Pixel brightness is a float from 0 to 1. Values above 0.5 trigger the SVG glow filter and a 1.1× scale, giving a convincing LED pop. Values between 0.05 and 0.5 render dim. Values below 0.05 render as an off-pixel.

Credits

From ui.elevenlabs/matrix

Keep in mind

Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.