Skip to content

[feat]: add GlyphMatrix animated background component #969

@sarvan-2187

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions