Skip to content

Glyph Matrix ​

An animated grid of subtly shifting glyphs on a canvas, with a theme-aware color driven by the consumer.

Installation ​

Copy and paste the following code into your project:

vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { cn } from "@/lib/utils";

interface GlyphMatrixProps {
  /** Classes applied to the canvas element */
  class?: string;
  /** 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;
  /** Fade out toward bottom (0 = no fade) */
  fadeBottom?: number;
  /** Glyph color (any CSS color). Pass a theme-aware value from the consumer. */
  color?: string;
}

const props = withDefaults(defineProps<GlyphMatrixProps>(), {
  glyphs: "01·•+*/\\<>=",
  cellSize: 14,
  mutationRate: 0.04,
  interval: 90,
  fadeBottom: 0.6,
  color: "#6B7280",
});

const canvasRef = ref<HTMLCanvasElement | null>(null);

// Current glyph color as RGBA (a in 0-1). Kept in a ref so a color change
// (e.g. theme toggle) recolors the next frame without restarting the
// animation. Defaults to #6B7280.
const rgba = ref({ r: 107, g: 114, b: 128, a: 1 });

// Resolve the CSS color string to RGBA (handles hex, rgb, hsl, oklch, ...).
const resolveColor = (color: string) => {
  if (typeof document === "undefined") return;
  const probe = document.createElement("canvas");
  probe.width = 1;
  probe.height = 1;
  const probeCtx = probe.getContext("2d");
  if (!probeCtx) return;
  // Seed with the default so an invalid color falls back to it: the 2d
  // context keeps the previous fillStyle when assigned an invalid value
  // instead of silently turning black.
  probeCtx.fillStyle = "#6B7280";
  probeCtx.fillStyle = color;
  probeCtx.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = probeCtx.getImageData(0, 0, 1, 1).data;
  rgba.value = { r, g, b, a: a / 255 };
};

let raf = 0;
let ro: ResizeObserver | null = null;
let stopped = false;

onMounted(() => {
  resolveColor(props.color);

  const canvas = canvasRef.value;
  if (!canvas) return;

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

  let cols = 0;
  let rows = 0;
  let cells: string[] = [];
  let alphas: number[] = [];
  let last = 0;

  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 / props.cellSize);
    rows = Math.ceil(h / props.cellSize);

    cells = Array.from(
      { length: cols * rows },
      () => props.glyphs[Math.floor(Math.random() * props.glyphs.length)],
    );
    alphas = Array.from({ length: cols * rows }, () => 0.05 + Math.random() * 0.35);
  };

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

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

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

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

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

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

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

      draw();
    }

    raf = requestAnimationFrame(tick);
  };

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

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

// Recolor the next frame when the color prop changes (e.g. theme toggle)
// without restarting the animation.
watch(
  () => props.color,
  (color) => resolveColor(color),
);

onBeforeUnmount(() => {
  stopped = true;
  cancelAnimationFrame(raf);
  ro?.disconnect();
});
</script>

<template>
  <canvas
    ref="canvasRef"
    :class="cn('pointer-events-none', props.class)"
    style="width: 100%; height: 100%; display: block"
    aria-hidden="true"
  />
</template>

Usage ​

Wrap the component in a sized, overflow-hidden container and drive its color from your theme:

vue
<script setup lang="ts">
import { computed } from "vue";
import { useData } from "vitepress";
import GlyphMatrix from "@/components/spark-ui/glyph-matrix/glyph-matrix.vue";

const { isDark } = useData();
const color = computed(() => (isDark.value ? "#ffffff" : "#000000"));
</script>

<template>
  <div class="relative h-[400px] w-full overflow-hidden rounded-lg border">
    <GlyphMatrix :color="color" />
  </div>
</template>

Props ​

PropTypeDefaultDescription
classstring-Classes applied to the canvas element.
glyphsstring"01·•+*/\\<>="Characters to randomly pick from.
cellSizenumber14Cell size in pixels and font size.
mutationRatenumber0.04Probability a cell mutates each tick.
intervalnumber90Tick interval in milliseconds.
fadeBottomnumber0.6Fade strength toward the bottom of the grid.
colorstring"#6B7280"Glyph color (any CSS color).

Notes ​

The component draws with the color prop, which accepts any CSS color (hex, rgb(), hsl(), oklch(), ...) and is normalized through canvas before drawing. For light/dark support, drive color from the consumer — e.g. with useData().isDark from VitePress as shown in the demo above.

Released under the MIT License.