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

PropTypeDefault
rows
number
-
cols
number
-
pattern?
Frame
-
frames?
Frame[]
-
fps?
number
12
autoplay?
boolean
true
loop?
boolean
true
size?
number
10
gap?
number
2
palette?
{ on: string; off: string }
{ on: "currentColor", off: "var(--muted-foreground)" }
brightness?
number
1
mode?
"default" | "vu"
"default"
levels?
number[]
-
onFrame?
(index: number) => void
-
ariaLabel?
string
"matrix display"
className?
string
-

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