Glyph Matrix
component / background / motion
A field of characters behind a hero that changes under the pointer and keeps what you leave behind, with a readability clearing and an opt-in glitch that tears two bands.
- type
- component
- files
- 1
- registry deps
- none
- npm deps
- none
installs
- components/automindz/glyph-matrix.tsx
- One canvas layer at a 14px cell — the pointer wrapper and the stylesheet are both gone
- Glyphs under the pointer change and stay: the glyph array is the field's only state
- clearing thins the field where the copy is — muted text measures 4.6:1 light, 7.5:1 dark
- mutation is a rate in Hz clamped to 1.5; a band cannot tear more often than every two seconds
- A frame costs the cells that changed: 0.20ms sweeping a 916 x 320 hero, 0.10ms at rest
**One file, no stylesheet, no npm dependency.** This was three files — a DOM field of <li> cells, a separate "use client" pointer wrapper, and a stylesheet you had to import in your root layout. It is now a single canvas layer, and both of the others are deleted: there is no @automindz/glyph-matrix-pointer to wrap your section in, and no styles/automindz-glyph.css to remember. <canvas> is a platform API, so dependencies is still empty. **The caller wires one thing: the parent.** Like Backdrop, GridBeams and FlickeringGrid, this renders absolute inset-0 -z-10, so its container needs relative isolate — without a stacking context the negative z-index escapes upward and the field paints behind an ancestor's background. That container is also the hover target, which is why the wrapper could go: the field is inset-0, so its parentElement is exactly the same box, and a layer at -z-10 with pointer-events: none can never receive a pointer event itself. <section className="relative isolate overflow-hidden py-24"><GlyphMatrix /><h1>…</h1></section> is the whole integration. **cellSize is a length in px, default 14** — the size the published implementations use, and the reason this moved to canvas. The DOM version capped the field at 320 cells because a pointer frame over more was measured at ~100ms, and that cap is what made the glyphs coarse; a 14px cell over a 1200 x 400 hero is ~2,500 cells, seven times it. A length rather than a column count, so the field is the same texture in a 360px card and a 2560px hero and simply has more of it. The pitch is floored at 6px and the cell count capped at 20,000, so a cellSize of a fraction gets a legible field instead of a locked tab. **What canvas cost, precisely: the server render.** GlyphMatrix used to have no "use client" and no client bundle, and its server HTML was the finished picture. A canvas is empty until it is measured, so a server render has no field in it; the layer is out of flow, so nothing moves when it arrives. Determinism went with it and stopped mattering — the hash existed only because Math.random() at render time is a hydration mismatch, and there is no server render to mismatch. What it bought back for free is the strongest accessibility guarantee: a canvas is pixels, so there is **no text node, no marker and no generated content**, and find-in-page has nothing to match in any browser. That used to take three separate promises and a caveat about Safari 17. **Glyphs under the pointer change character and keep it.** A cell coming inside the pointer's reach rolls to a different character, and that becomes its character when the pointer moves on — the glyph array is the field's only state, so there is nothing to spring back to and the field evolves as you sweep it. Each cell's reach is jittered across 0.55x–1.25x of the nominal, so the boundary of the changed region is ragged and dissolves rather than sliding around as a clean disc. reach (default 0.09, a share of the field's width) governs both the pool and the brightness lift, so the two cannot disagree. The pointer stands down under (hover: none) and prefers-reduced-motion: reduce. **clearing is the readability fix, and it is measured rather than asserted.** A soft elliptical clearing over the middle of the box thins the field where your copy is instead of dimming it everywhere: flat across a centred content column, ramping out before the sides, so the edges and corners keep full texture. Its floor is not a look — it is an ink ceiling of 0.012, half the alpha at which text-muted-foreground (4.74:1 on bare bg-background, a quarter of a point of headroom) crosses 4.5:1, halved because two neighbouring glyphs' antialiased edges composite. A heavier intensity therefore gets a deeper clearing on its own, with nothing to re-tune. Sampled off the canvas behind real copy: muted text reads **4.6:1 in light and 7.5:1 in dark at every intensity**, against 19:1 for text-foreground. This replaces the contrast caveat this item used to carry. Two things void it: clearing={0} takes muted to 2.2:1, which is that old caveat exactly; and the clearing is **centred**, so copy pinned to an edge, or filling a narrow box corner to corner, sits outside the core and gets the field at close to full strength. **Cadence is capped in arithmetic, not in this string.** mutation is a rate in Hz — how often one cell changes character, default 0.45 — so WCAG 2.3.1's three-flash threshold is directly comparable, and glyphMatrixRate() clamps it to 1.5Hz, half of it. glyphMatrixStep() clamps a frame to 1/20s, so a tab returning from the background does not pay out thirty seconds of churn on one frame and pop. glyphMatrixTearCycle() floors a band's lap at two seconds, so a band cannot tear more often than 0.5Hz. **glitch tears the field**: two bands slip sideways and churn their characters in three hard cuts, then land back in alignment carrying the new ones. 96% of a band's lap is spent at rest, and it is gone entirely under prefers-reduced-motion, where the field is drawn once and left still. **A frame costs the cells that changed, not the cells that exist** — the DOM version's hard-won lesson, and not automatic on canvas, because fillText is not fillRect. The canvas is fully repainted only on resize or a theme change; every frame collects the handful of cells that mutated, the pool under a moving pointer and any band mid-tear, clears their boxes in one pass and redraws them in the next. Measured on the docs page: **0.20ms per frame while sweeping a 916 x 320 field and 0.10ms at rest**, against 4.0ms and 0.07ms for the DOM version — and the 0.07ms counted only JavaScript, where this figure is the whole cost. An IntersectionObserver stops the loop off screen. **Colour and the mono face come off the element, never a prop and never a hex.** The published implementations take a color string defaulting to a fixed grey, which is invisible on a dark surface. This reads the layer's computed color and font-family, so text-foreground and font-mono are the defaults and text-primary is the whole API; a MutationObserver over every element from the layer up to <html> and a prefers-color-scheme listener catch both halves of a theme change — so .dark on a section's own wrapper works, not just on a document root — and because the chain starts at the layer, switching intensity replots the clearing. glyphs is a string; GLYPH_SETS ships terminal, katakana and binary, all free of Latin letters because a field of them reads as words the eye keeps trying to finish. A one-character set keeps its glyph rather than looping forever. **Accessibility.** aria-hidden on the root and pointer-events: none on both the layer and the canvas — a CTA over the field is clicked, not clicked through. A zero-size container renders nothing rather than dividing by zero, and there is no loading state because the layer takes no data. Dials, both custom properties on the root: --automindz-glyph-fade (the edge mask layer list) and --automindz-glyph-alpha / --automindz-glyph-alpha-dark (the per-theme weight, which intensity sets for you).
Variations
one state per section
- 01Default, a 14px cell — move the pointer over it
- 02The readability clearing
- 03Cell size
- 04Glitching
- 05Rate, and the ceiling
- 06Katakana, tinted
Default, a 14px cell — move the pointer over it
The characters under the cursor change, and they stay: sweep an area, move away, and the glyphs you left behind are the new ones. Watch the edge of the pool — every cell's reach is jittered across 0.55×–1.25× of the nominal, so the boundary is ragged rather than a disc sliding under the mouse. The whole layer is a canvas, so there is nothing here to select and nothing for Ctrl+F to find.
It keeps what you leave behind
A cell entering the pointer's reach rolls to a different character and that becomes its character. The glyph array is the field's only state, so there is nothing to spring back to.
The readability clearing
clearing thins the field where the copy is instead of dimming it everywhere: a flat elliptical core over the middle, ramping out before the sides, so the edges and corners keep full texture. Its floor is not a look — it is an ink ceiling of 0.012, half the alpha at which text-muted-foreground (4.74:1 on bare background, a quarter of a point of headroom) crosses 4.5:1, halved because two neighbouring glyphs' antialiased edges composite. A heavier intensity therefore gets a deeper clearing on its own. Sampled off the canvas behind this copy: muted measures 4.6:1 in light and 7.5:1 in dark. The lower panel is the same strong field with clearing={0} — 2.2:1, which is what the muted line has to survive without it.
clearing={1} — the default
Muted metadata is the hard case: it starts at 4.74:1 on bare background, so any texture under it drops below 4.5:1.
clearing={0}
Muted metadata is the hard case: it starts at 4.74:1 on bare background, so any texture under it drops below 4.5:1.
Cell size
cellSize is a length in px, not a column count, so the field is the same texture in a 360px card and a 2560px hero — it simply has more of it. 14 is the default and what the published implementations use; 28 is the coarse terminal this item used to be limited to. The pitch is floored at 6px and the cell count capped at 20 000, so a cellSize of 1 gets a legible field instead of a locked tab.
Glitching
glitch tears two bands: a little over half of a row slips sideways and churns its characters in three hard cuts, then lands back in alignment carrying the new ones. A band tears once every nine seconds here and glyphMatrixTearCycle() will not let it go under two — a sixth of WCAG 2.3.1's threshold — and it is gone entirely under prefers-reduced-motion.
A tear, not a filter
Only the row mid-cut is redrawn, and only on the four frames its offset actually changes — the other 96% of a band's lap costs nothing at all.
Rate, and the ceiling
mutation is a rate in Hz — how often one cell changes character — so 0.1 is a slow drift and 0.45 is the default. The third tile asks for 6Hz and gets 1.5, half the WCAG flash threshold; the cap is arithmetic in glyphMatrixRate(), not a line in the docs. A frame costs the cells that changed, so the rate is also the per-frame budget.
Katakana, tinted
glyphs is a string; GLYPH_SETS ships terminal, katakana and binary. There is no colour prop and no hex: the glyphs are painted in the layer's computed color, so text-primary is the whole API and the field follows the consumer's theme in both schemes. The mono face is read off the element too, so your font stack draws it.
Bring your own alphabet
Latin letters are left out of all three sets: a field of them reads as words the eye keeps trying to finish, which is the alphabet-soup failure this item exists to avoid.
