Skip to content

[feat]: add GlyphMatrix animated background component #969

Description

@sarvan-2187

Feature description

Overview

Add a new component called GlyphMatrix to Magic UI.

GlyphMatrix renders an animated grid of glyphs/symbols on a where characters subtly mutate over time, creating a futuristic terminal / cyberpunk / matrix-like effect while remaining lightweight and theme-aware.

This component is especially useful for:

  • Hero section backgrounds
  • AI / developer portfolios
  • Terminal-inspired UIs
  • Dashboard ambient effects
  • Cyberpunk aesthetics
  • Interactive landing pages

Proposed API

interface GlyphMatrixProps {
  /** Characters to randomly pick from */
  glyphs?: string;

  /** Cell size in px (also font size) */
  cellSize?: number;

  /** Probability (0-1) a cell mutates each tick */
  mutationRate?: number;

  /** Tick interval in ms */
  interval?: number;

  /** Optional className for the wrapping canvas */
  className?: string;

  /** Fade out toward bottom (0 = no fade) */
  fadeBottom?: number;
}

Features

  • Lightweight canvas-based rendering
  • Theme-aware using CSS variables (--foreground)
  • Responsive resizing via ResizeObserver
  • Smooth animation with requestAnimationFrame
  • Configurable glyph set
  • Adjustable mutation speed/intensity
  • Subtle opacity variation for depth
  • Optional bottom fade effect
  • Works well in both light and dark themes

Example Usage

import { GlyphMatrix } from "@/components/magicui/glyph-matrix";

export default function Demo() {
  return (
    <div className="relative h-[500px] w-full overflow-hidden rounded-xl border">
      <GlyphMatrix
        className="absolute inset-0"
        glyphs="01アイウエオ<>[]{}"
        cellSize={16}
        mutationRate={0.05}
        interval={80}
        fadeBottom={0.7}
      />

      <div className="relative z-10 flex h-full items-center justify-center">
        <h1 className="text-4xl font-bold">
          Glyph Matrix
        </h1>
      </div>
    </div>
  );
}

Expected File Structure

components/magicui/glyph-matrix.tsx

Additional Notes

  • Uses for performance instead of DOM nodes
  • Avoid external animation dependencies
  • Should follow existing Magic UI component conventions
  • Could later support:
    • directional glyph flow
    • glow intensity
    • interactive mouse effects
    • color gradients
    • noise-based motion

JSX Version (Javascript)

"use client";

import { useEffect, useRef } from "react";

export function GlyphMatrix({
  glyphs = "01·•+*/\\<>=",
  cellSize = 14,
  mutationRate = 0.04,
  interval = 90,
  className,
  fadeBottom = 0.6,
}) {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let cols = 0;
    let rows = 0;
    let cells = [];
    let alphas = [];
    let raf = 0;
    let last = 0;
    let stopped = false;

    const readColor = () => {
      const styles = getComputedStyle(canvas);

      const probe = document.createElement("span");
      probe.style.color = "var(--foreground)";
      probe.style.display = "none";

      canvas.parentElement?.appendChild(probe);

      const color = getComputedStyle(probe).color || styles.color;

      probe.remove();

      return color;
    };

    let fgColor = readColor();

    const resize = () => {
      const dpr = window.devicePixelRatio || 1;

      const { clientWidth: w, clientHeight: h } = canvas;

      canvas.width = w * dpr;
      canvas.height = h * dpr;

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      cols = Math.ceil(w / cellSize);
      rows = Math.ceil(h / cellSize);

      cells = new Array(cols * rows)
        .fill(0)
        .map(
          () => glyphs[Math.floor(Math.random() * glyphs.length)]
        );

      alphas = new Array(cols * rows)
        .fill(0)
        .map(() => 0.05 + Math.random() * 0.35);

      fgColor = readColor();
    };

    const parseRgb = (c) => {
      const m = c.match(/rgba?\(([^)]+)\)/);

      if (!m) {
        return { r: 0, g: 0, b: 0 };
      }

      const [r, g, b] = m[1]
        .split(",")
        .map((v) => parseFloat(v));

      return { r, g, b };
    };

    const draw = () => {
      const { clientWidth: w, clientHeight: h } = canvas;

      ctx.clearRect(0, 0, w, h);

      const { r, g, b } = parseRgb(fgColor);

      ctx.font = `${
        cellSize - 2
      }px ui-monospace, SFMono-Regular, Menlo, monospace`;

      ctx.textBaseline = "top";

      for (let y = 0; y < rows; y++) {
        const fade =
          fadeBottom > 0
            ? 1 - (y / rows) * fadeBottom
            : 1;

        for (let x = 0; x < cols; x++) {
          const i = y * cols + x;

          const a = alphas[i] * fade;

          ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;

          ctx.fillText(
            cells[i],
            x * cellSize,
            y * cellSize
          );
        }
      }
    };

    const tick = (t) => {
      if (stopped) return;

      if (t - last >= interval) {
        last = t;

        const total = cols * rows;

        const mutations = Math.max(
          1,
          Math.floor(total * mutationRate)
        );

        for (let n = 0; n < mutations; n++) {
          const i = Math.floor(Math.random() * total);

          cells[i] =
            glyphs[
              Math.floor(Math.random() * glyphs.length)
            ];

          alphas[i] = 0.05 + Math.random() * 0.45;
        }

        draw();
      }

      raf = requestAnimationFrame(tick);
    };

    resize();
    draw();

    raf = requestAnimationFrame(tick);

    const ro = new ResizeObserver(() => {
      resize();
      draw();
    });

    ro.observe(canvas);

    const mo = new MutationObserver(() => {
      fgColor = readColor();
    });

    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });

    return () => {
      stopped = true;

      cancelAnimationFrame(raf);

      ro.disconnect();
      mo.disconnect();
    };
  }, [
    glyphs,
    cellSize,
    mutationRate,
    interval,
    fadeBottom,
  ]);

  return (
    <canvas
      ref={canvasRef}
      className={className}
      style={{
        width: "100%",
        height: "100%",
        display: "block",
      }}
      aria-hidden="true"
    />
  );
}

export default GlyphMatrix;

TSX Version (Typescript)

"use client"

import { useEffect, useRef } from "react"

interface GlyphMatrixProps {
  /** Characters to randomly pick from */
  glyphs?: string
  /** Cell size in px (also font size) */
  cellSize?: number
  /** Probability (0-1) a cell mutates each tick */
  mutationRate?: number
  /** Tick interval in ms */
  interval?: number
  /** Optional className for the wrapping canvas */
  className?: string
  /** Fade out toward bottom (0 = no fade) */
  fadeBottom?: number
}

/**
 * GlyphMatrix — an animated grid of subtly shifting glyphs.
 * Uses semantic tokens (--foreground / --background) so it adapts to
 * both light and dark modes automatically.
 */
export function GlyphMatrix({
  glyphs = "01·•+*/\\<>=",
  cellSize = 14,
  mutationRate = 0.04,
  interval = 90,
  className = "text-black dark:text-white",
  fadeBottom = 0.6,
}: GlyphMatrixProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    const ctx = canvas.getContext("2d")
    if (!ctx) return

    let cols = 0
    let rows = 0
    let cells: string[] = []
    let alphas: number[] = []
    let raf = 0
    let last = 0
    let stopped = false

    const readColor = () => {
      const styles = getComputedStyle(canvas)
      // Resolve --foreground via a temp element so oklch() is converted to rgb
      const probe = document.createElement("span")
      probe.style.color = "var(--foreground)"
      probe.style.display = "none"
      canvas.parentElement?.appendChild(probe)
      const color = getComputedStyle(probe).color || styles.color
      probe.remove()
      return color
    }

    let fgColor = readColor()

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      const { clientWidth: w, clientHeight: h } = canvas

      canvas.width = w * dpr
      canvas.height = h * dpr
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)

      cols = Math.ceil(w / cellSize)
      rows = Math.ceil(h / cellSize)

      cells = new Array(cols * rows)
        .fill(0)
        .map(() => glyphs[Math.floor(Math.random() * glyphs.length)])
      alphas = new Array(cols * rows)
        .fill(0)
        .map(() => 0.05 + Math.random() * 0.35)

      fgColor = readColor()
    }

    const parseRgb = (c: string) => {
      const m = c.match(/rgba?\(([^)]+)\)/)
      if (!m) return { r: 0, g: 0, b: 0 }
      const [r, g, b] = m[1].split(",").map((v) => parseFloat(v))
      return { r, g, b }
    }

    const draw = () => {
      const { clientWidth: w, clientHeight: h } = canvas
      ctx.clearRect(0, 0, w, h)

      const { r, g, b } = parseRgb(fgColor)
      ctx.font = `${cellSize - 2}px ui-monospace, SFMono-Regular, Menlo, monospace`
      ctx.textBaseline = "top"

      for (let y = 0; y < rows; y++) {
        const fade = fadeBottom > 0 ? 1 - (y / rows) * fadeBottom : 1
        for (let x = 0; x < cols; x++) {
          const i = y * cols + x
          const a = alphas[i] * fade
          ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`
          ctx.fillText(cells[i], x * cellSize, y * cellSize)
        }
      }
    }

    const tick = (t: number) => {
      if (stopped) return

      if (t - last >= interval) {
        last = t

        const total = cols * rows
        const mutations = Math.max(1, Math.floor(total * mutationRate))

        for (let n = 0; n < mutations; n++) {
          const i = Math.floor(Math.random() * total)
          cells[i] = glyphs[Math.floor(Math.random() * glyphs.length)]
          alphas[i] = 0.05 + Math.random() * 0.45
        }

        draw()
      }

      raf = requestAnimationFrame(tick)
    }

    resize()
    draw()
    raf = requestAnimationFrame(tick)

    const ro = new ResizeObserver(() => {
      resize()
      draw()
    })
    ro.observe(canvas)

    // Re-read color when theme changes (class on <html>)
    const mo = new MutationObserver(() => {
      fgColor = readColor()
    })
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    })

    return () => {
      stopped = true
      cancelAnimationFrame(raf)
      ro.disconnect()
      mo.disconnect()
    }
  }, [glyphs, cellSize, mutationRate, interval, fadeBottom])

  return (
    <canvas
      ref={canvasRef}
      className={className}
      style={{ width: "100%", height: "100%", display: "block" }}
      aria-hidden="true"
    />
  )
}

export default GlyphMatrix

Affected component/components

None.

Additional Context

It is already implemented in my portfolio at www.sarvankumar.in.

Screenshot

Image

Before submitting

  • I've made research efforts and searched the documentation
  • I've searched for existing issues and PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions