Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/www/registry/example/glyph-matrix-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { GlyphMatrix } from "@/registry/magicui/glyph-matrix"

export default function GlyphMatrixDemo() {
return (
<div className="relative w-full h-full bg-background rounded-lg border border-border overflow-hidden">
<GlyphMatrix
glyphs="01·•+*/\\<>="
cellSize={14}
mutationRate={0.04}
interval={90}
fadeBottom={0.6}
/>
</div>
)
}
169 changes: 169 additions & 0 deletions apps/www/registry/magicui/glyph-matrix.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"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,
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?\(([^)]+)\)/);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex only matches rgb()/rgba(), but shadcn/Tailwind v4 tokens resolve to oklch(...).

On a miss this falls back to {0,0,0}, making the glyphs invisible in dark mode

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;
13 changes: 13 additions & 0 deletions apps/www/registry/registry-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,19 @@ export const examples: Registry["items"] = [
},
],
},
{
name: "glyph-matrix-demo",
type: "registry:example",
title: "Glyph Matrix Demo",
description: "Example showing an animated grid of subtly shifting glyphs.",
registryDependencies: ["@magicui/glyph-matrix"],
files: [
{
path: "example/glyph-matrix-demo.tsx",
type: "registry:example",
},
],
},
{
name: "glare-hover-demo",
type: "registry:example",
Expand Down
13 changes: 13 additions & 0 deletions apps/www/registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,19 @@ export const ui: Registry["items"] = [
},
],
},
{
name: "glyph-matrix",
type: "registry:ui",
title: "Glyph Matrix",
description:
"An animated grid of subtly shifting glyphs with fade effect and theme support.",
files: [
{
path: "magicui/glyph-matrix.tsx",
type: "registry:ui",
},
],
},
{
name: "glare-hover",
type: "registry:ui",
Expand Down
13 changes: 13 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@
}
}
},
{
"name": "glyph-matrix",
"type": "registry:ui",
"title": "Glyph Matrix",
"description": "An animated grid of subtly shifting glyphs with fade effect and theme support.",
"files": [
{
"path": "registry/magicui/glyph-matrix.tsx",
"type": "registry:ui",
"target": "components/magicui/glyph-matrix.tsx"
}
]
},
{
"name": "globe",
"type": "registry:ui",
Expand Down
Loading