# Glyph Field (Moonarc)

A field of braille dots that reads as a flowing contour map: two waves cross it, the colour turns along a sunset arc, and the cursor carves a trail that decays one dot at a time. No canvas and no frame loop: every cell is an element whose glyph is a CSS counter on a registered integer, the contours are noise computed on the server, and the waves are one stepping animation per row. ASCII and block sets are one prop away. Fills its positioned parent.

- Import: `import GlyphField from '@moonarc/core/GlyphField'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/glyph-field.json`
- Tier A · category pointer · trigger pointer, always
- Readout: `<GlyphField cols={48} rows={20}>`
- Browser support: newly (Chrome 125 · Firefox 128 · Safari 17.2); elsewhere: without mod() the waves stop and the field shows the server-drawn contours; without @property the hover lights and releases in one step instead of decaying glyph by glyph
- Measured cost: 0 B JS (CSS 7.8 kB raw)
- Page: https://moonarc.dev/components/glyph-field/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `cols` | `number` | `48` | Columns, max 96. The cost is HTML: cols × rows elements at about 40 B raw each, 1.3 kB gzip at 48 × 20. |
| `rows` | `number` | `20` | Rows, max 64. |
| `seed` | `number` | `7` | Seed for the server-side value noise. The same seed always draws the same contours, so the HTML is stable across builds. |
| `hue` | `number` | `68` | Base hue in oklch degrees at the top-left cell. 68 is amber; 250 is indigo. |
| `spread` | `number` | `3` | Degrees the hue turns per cell along the diagonal. 3 sweeps amber → rose → violet across 48 × 20; 0 is a single hue; negative turns the other way. |
| `chroma` | `number` | `0.14` | Chroma of the glyphs. 0.02 reads as ink, 0.2 as neon. Dense glyphs get a little more. |
| `speed` | `number` | `6` | Seconds per wave cycle. The second wave takes 2.5× as long and is one step lower, so the crests never line up. |
| `amp` | `number` | `3` | Wave height in glyph steps, 0–4. A cell at rest is 1–5; a crest adds up to this; the cursor takes it to 8 (#). |
| `fade` | `number` | `900` | Milliseconds a hovered cell takes to decay back, one glyph per step, from eight dots down to one. |
| `reach` | `number` | `5` | Swish radius in cells around the cursor; the tail is one step narrower per older cell. |
| `glyphs` | `'braille' | 'ascii' | 'blocks'` | `'braille'` | Glyph set, eight symbols in rising density each: braille ⠁…⣿, ascii · - = + / \ | #, or shade blocks. Any font that has them works because a glyph is centred in its own cell. |
| `wave` | `boolean` | `true` | Run the two waves. |
| `glow` | `boolean` | `true` | Three soft colour fields drift behind the glyphs on the compositor. |
| `swish` | `boolean` | `false` | Inline one pointermove listener so the field lifts in two dimensions around the cursor and a comet tail follows the last three cells it crossed. It writes one integer on each touched cell (about 150 a crossing, diffed) and never on the field. Without it the trail is :hover only: still a trail, one row deep. |

## Usage

```astro
<section class="relative min-h-[70vh]">
  <GlyphField swish />
  <h1 class="relative">Move your cursor. The field is CSS.</h1>
</section>
```

## Reduced motion

The waves and the bloom stop; the field shows the server-drawn contours. Cells still light under the cursor, because that is direct manipulation, but release in one step, with no tail. With swish, the halo follows and the tail is not recorded; the setting and the page's data-ma-motion switch are read on every move.

## With ClientRouter

CSS-only by default; nothing to rebind. The swish listener binds itself again after every navigation, once per field.

## Craft

- The glyph is content: counter() on a registered <integer>. Integers interpolate in steps, so a 900 ms transition from 7 to 0 rewrites the character seven times. The decay is the browser stepping through the density ramp, not a fade.
- Braille by default: one dot to eight is a density ramp every system font carries, and dots read as texture where slashes and hashes read as noise. ASCII is there for the terminal look.
- The waves are twenty animations for 960 cells: each row steps an inherited integer phase; each cell folds its own column into that phase with mod(), and takes the distance as max(d, -d) (abs() arrived in Chrome 138, mod() in 125). Style work happens only when a row steps (48 cells at a time, about 1.5 ms a frame on the site hero), and content-visibility: auto makes a field that has scrolled past cost nothing.
- Swish writes each touched cell directly. The first build wrote the cursor cell on the root and let every cell derive its distance in calc(): elegant, and every crossing recalculated all 960 cells, ~7 ms. Writing the halo as one inline integer per touched cell, diffed frame to frame, restyles ~150 and costs ~2 ms.
- The contours are value noise on the server, two octaves with a contrast curve so the field is mostly quiet with bands of denser glyphs. Deterministic per seed: the fixture is reproducible and the HTML is stable.
- Colour is per cell, computed: hue = base − (x + y) × spread, lightness and chroma rise with density. A sunset arc (amber → rose → violet) at moderate chroma reads as expensive; a full rainbow at full chroma reads as a screensaver.
- Lit in 0 ms, released over 900 ms: asymmetry is what reads as a trail. Two neighbours each side at 60 and 120 ms so a fast cursor leaves a continuous stroke.
- The bloom is three radial fields on the same hue arc, translated on the compositor behind the glyphs, and a radial mask fades the field at its edges. That gives depth without a blur filter.
- contain: strict on the field and on every cell: a glyph change lays out one 30 px box. aria-hidden: 960 elements assistive tech never meets.

## Replaces

- ASCII canvas hero (motion.dev)
- ASCIIText / LetterGlitch (React Bits)
- canvas character fields

## Source

```astro
---
/**
 * GlyphField — a field of braille dots that reads as a flowing contour map.
 * Two waves cross it, the colour turns along a sunset arc, and the cursor
 * carves a trail that decays one dot at a time. No canvas, no frame loop.
 *
 * Every cell is an element whose glyph is a CSS counter on a registered
 * integer: change the integer, change the character. Because integers step,
 * every transition looks like ASCII being rewritten, never a fade. The base
 * pattern is value noise computed on the server (two octaves, seeded), so
 * the contours are already in the HTML. The waves are one animation per
 * row stepping an inherited integer; each cell derives its own crest with
 * mod() and max() (an engine without mod() keeps the contours still). The
 * trail is :hover released on a slow integer
 * transition. `swish` inlines one pointer listener (measured, printed) that
 * keeps the last three cells the cursor crossed and writes a halo around
 * each — head widest, tail narrower — as one integer per touched cell,
 * diffed, so a crossing restyles ~150 elements and never the field.
 *
 * Measured on the site's 48 × 20 hero (Chromium, M-series): the waves cost
 * ~1.5 ms of style work per frame while the field is on screen and nothing
 * once it is scrolled past (content-visibility: auto); a cursor crossing
 * costs ~2 ms. Writing the cursor on the root and deriving the halo in
 * calc() was tried first: every crossing then recalculated all 960 cells,
 * ~7 ms — that is why the listener writes cells.
 *
 * The cost is HTML, not script: cols × rows elements at ~40 B each
 * (48 × 20: 38.8 kB raw, 1.3 kB gzipped, measured 2026-09-28). Fills its
 * positioned parent; put content above it.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Columns, max 96. */
  cols?: number;
  /** Rows, max 64. */
  rows?: number;
  /** Seed for the server-side noise; the same seed always draws the same contours. */
  seed?: number;
  /** Base hue in degrees (oklch) at the top-left cell. */
  hue?: number;
  /** Degrees the hue turns per cell along the diagonal; 3 sweeps ~200° across 48 × 20. Negative turns the other way. */
  spread?: number;
  /** Chroma of the glyphs, 0–0.3. Low reads as ink, high as neon. */
  chroma?: number;
  /** Seconds per wave cycle; the second wave takes 2.5× as long. */
  speed?: number;
  /** Wave height in glyph steps, 0–4. */
  amp?: number;
  /** Milliseconds a hovered cell takes to decay back, one glyph per step. */
  fade?: number;
  /** Swish radius in cells around the cursor. */
  reach?: number;
  /** Glyph set: braille (one to eight dots), ascii (· - = + / \ | #) or blocks. */
  glyphs?: 'braille' | 'ascii' | 'blocks';
  /** Run the two waves. */
  wave?: boolean;
  /** Soft colour bloom behind the glyphs. */
  glow?: boolean;
  /** Inline one pointermove listener so the field warps in two dimensions around the cursor, with a tail. */
  swish?: boolean;
}

const {
  cols = 48,
  rows = 20,
  seed = 7,
  hue,
  spread,
  chroma,
  speed,
  amp,
  fade,
  reach,
  glyphs = 'braille',
  wave = true,
  glow = true,
  swish = false,
  class: className,
  style,
  ...rest
} = Astro.props;

const C = Math.max(1, Math.min(Math.round(cols), 96));
const R = Math.max(1, Math.min(Math.round(rows), 64));

// Value noise, two octaves, seeded (mulberry32). Deterministic per seed, so the
// server HTML is stable across builds and the fixture is reproducible.
function mulberry32(a: number) {
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(Math.floor(seed) || 1);
const lattice = (period: number) => Array.from({ length: Math.ceil(R / period) + 2 }, () => Array.from({ length: Math.ceil(C / period) + 2 }, rand));
const smooth = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const sample = (lat: number[][], period: number, x: number, y: number) => {
  const gx = x / period;
  const gy = y / period;
  const x0 = Math.floor(gx);
  const y0 = Math.floor(gy);
  const tx = smooth(gx - x0);
  const ty = smooth(gy - y0);
  return lerp(lerp(lat[y0]![x0]!, lat[y0]![x0 + 1]!, tx), lerp(lat[y0 + 1]![x0]!, lat[y0 + 1]![x0 + 1]!, tx), ty);
};
const l1 = lattice(7);
const l2 = lattice(3);
// 1..5 with contrast pulled up so the field is mostly quiet with bands of denser glyphs; the waves and the cursor add up to 8.
const cells = Array.from({ length: R }, (_, y) =>
  Array.from({ length: C }, (_, x) => {
    const n = 0.7 * sample(l1, 7, x, y) + 0.3 * sample(l2, 3, x, y);
    const c = Math.min(1, Math.max(0, (n - 0.5) * 1.9 + 0.5));
    return 1 + Math.floor(Math.pow(c, 1.5) * 4.99);
  }),
);

const vars = [
  hue !== undefined && `--ma-gf-hue:${hue}deg`,
  spread !== undefined && `--ma-gf-spread:${spread}deg`,
  chroma !== undefined && `--ma-gf-chroma:${chroma}`,
  speed !== undefined && `--ma-gf-speed:${speed}s`,
  amp !== undefined && `--ma-gf-amp:${amp}`,
  fade !== undefined && `--ma-gf-fade:${fade}ms`,
  reach !== undefined && `--ma-gf-reach:${reach}`,
  typeof style === 'string' ? style : '',
]
  .filter(Boolean)
  .join(';');

// The swish listener, inlined only when asked for so the default stays at 0 B.
// Self-contained: binds now and after every ClientRouter swap, once per field.
// Keeps the last three cells the cursor crossed; once per frame it writes the
// halo around each (reach, reach − 2, reach − 3 wide, Chebyshev falloff) as an
// inline --ma-gf-s on the touched cells, diffed against the previous frame so
// only cells whose value changed are written. After 140 ms of stillness the
// tail collapses one cell at a time; leaving the field clears it. Reduced
// motion (the query or the page's data-ma-motion switch) is read on every
// move, and Astro's page events fire on document, where the rebind listens.
// Every copy of the script calls the binder, so a second field on the first
// page is bound too.
const SWISH = `(window.__maGf||(window.__maGf=function(){document.querySelectorAll('.ma-gf[data-swish]').forEach(function(f){if(f.__ma)return;f.__ma=1;var rows=[].map.call(f.children,function(b){return b.children}),R=rows.length,C=rows[0].length,cur={},q=[],t,raf,rm=matchMedia('(prefers-reduced-motion: reduce)'),w=function(){var n={},k,r=+f.style.getPropertyValue('--ma-gf-reach')||5;q.forEach(function(p,i){var a=r-[0,2,3][i];for(var y=p[1]-a;y<=p[1]+a;y++)if(y>=0&&y<R)for(var x=p[0]-a;x<=p[0]+a;x++)if(x>=0&&x<C){var v=a-Math.max(Math.abs(x-p[0]),Math.abs(y-p[1]));k=y*C+x;if(v>0&&!(n[k]>=v))n[k]=v}});for(k in cur)if(!n[k])rows[k/C|0][k%C].style.removeProperty('--ma-gf-s');for(k in n)if(cur[k]!==n[k])rows[k/C|0][k%C].style.setProperty('--ma-gf-s',n[k]);cur=n},d=function(){if(q.length>1){q.pop();w();t=setTimeout(d,140)}};f.addEventListener('pointermove',function(e){var b=f.getBoundingClientRect(),x=Math.floor((e.clientX-b.left)/b.width*C),y=Math.floor((e.clientY-b.top)/b.height*R),h=q[0];if(h&&h[0]==x&&h[1]==y)return;q.unshift([x,y]);q.length=rm.matches||document.documentElement.dataset.maMotion=='reduce'?1:Math.min(q.length,3);clearTimeout(t);t=setTimeout(d,140);raf||(raf=requestAnimationFrame(function(){raf=0;w()}))},{passive:true});f.addEventListener('pointerleave',function(){clearTimeout(t);q=[];w()})})},document.addEventListener('astro:page-load',window.__maGf),document.addEventListener('astro:after-swap',window.__maGf),window.__maGf))()`;
---

<div
  class:list={['ma-gf', className]}
  aria-hidden="true"
  data-ma-gf
  data-wave={wave ? '' : undefined}
  data-glow={glow ? '' : undefined}
  data-swish={swish ? '' : undefined}
  data-glyphs={glyphs !== 'braille' ? glyphs : undefined}
  style={vars || undefined}
  {...rest}
>
  {cells.map((row, y) => <b style={`--ma-gf-y:${y}`}>{row.map((g, x) => <i style={`--ma-gf-x:${x};--ma-gf-g:${g}`}></i>)}</b>)}
</div>
{swish && <script is:inline set:html={SWISH} />}

<style is:global>
  /* wave phases, one per row, inherited by the cells */
  @property --ma-gf-p {
    syntax: '<integer>';
    inherits: true;
    initial-value: 0;
  }
  @property --ma-gf-q {
    syntax: '<integer>';
    inherits: true;
    initial-value: 0;
  }
  /* per cell: hover lift (transitions), swish lift (written inline by the listener), and the total the counter reads */
  @property --ma-gf-h {
    syntax: '<integer>';
    inherits: false;
    initial-value: 0;
  }
  @property --ma-gf-s {
    syntax: '<integer>';
    inherits: false;
    initial-value: 0;
  }
  @property --ma-gf-v {
    syntax: '<integer>';
    inherits: false;
    initial-value: 1;
  }

  @layer components {
    /* eight densities; the counter value 1–8 picks one. Braille is the default: one dot to eight, every font has them, and a dot reads as texture where a slash reads as noise */
    @counter-style ma-glyphs-braille {
      system: cyclic;
      symbols: '⠁' '⠃' '⠇' '⠏' '⠟' '⠿' '⡿' '⣿';
    }
    @counter-style ma-glyphs-ascii {
      system: cyclic;
      symbols: '·' '-' '=' '+' '/' '\\' '|' '#';
    }
    @counter-style ma-glyphs-blocks {
      system: cyclic;
      symbols: '·' '░' '░' '▒' '▒' '▓' '▓' '█';
    }

    :where(.ma-gf) {
      position: absolute;
      inset: 0;
      display: grid;
      grid-auto-rows: 1fr;
      overflow: hidden;
      contain: strict;
      /* the field is the query container the column rule below reads; each cell is its own, for the glyph size */
      container-type: size;
      /* off screen the cells are skipped entirely — no style, layout or paint — so a field scrolled past costs nothing */
      content-visibility: auto;
      font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
      font-weight: 500;
      /* lightness follows the theme through the base scalar --ma-dark (0 light, 1 dark): dense glyphs go darker on a light ground, brighter on a dark one */
      --ma-gf-l: calc(66% - 16% * var(--ma-dark, 0));
      --ma-gf-lv: calc(-3.2% + 7.6% * var(--ma-dark, 0));
      mask-image: radial-gradient(130% 110% at var(--ma-gf-focus, 50% 50%), #000 30%, transparent 100%);
    }

    /* bloom: three soft fields on the same hue arc as the glyphs — the second and third sit 40 and 20 cells along the spread — drifting on the compositor behind them */
    :where(.ma-gf[data-glow])::before {
      content: '';
      position: absolute;
      inset: -25%;
      z-index: -1;
      background:
        radial-gradient(38% 42% at 28% 36%, oklch(62% 0.17 var(--ma-gf-hue, 68deg) / 0.42), transparent 70%),
        radial-gradient(42% 46% at 72% 58%, oklch(50% 0.18 calc(var(--ma-gf-hue, 68deg) - var(--ma-gf-spread, 3deg) * 40) / 0.4), transparent 70%),
        radial-gradient(30% 34% at 52% 82%, oklch(56% 0.16 calc(var(--ma-gf-hue, 68deg) - var(--ma-gf-spread, 3deg) * 20) / 0.34), transparent 70%);
      will-change: translate;
      animation: ma-gf-glow calc(var(--ma-gf-speed, 6s) * 4) ease-in-out infinite alternate;
    }

    :where(.ma-gf > b) {
      display: flex;
      min-height: 0;
    }
    /* the waves: an integer phase stepping 16 (and 24) times a cycle, one animation per row, offset per row so the crests run diagonally; the second wave is slower and shallower so the two never line up */
    :where(.ma-gf[data-wave] > b) {
      animation:
        ma-gf-wave var(--ma-gf-speed, 6s) linear infinite,
        ma-gf-wave-2 calc(var(--ma-gf-speed, 6s) * 2.5) linear infinite;
      animation-delay: calc(var(--ma-gf-y) * var(--ma-gf-speed, 6s) / -16), calc(var(--ma-gf-y) * var(--ma-gf-speed, 6s) * 2.5 / -48);
    }

    :where(.ma-gf > b > i) {
      flex: 1 1 0;
      min-width: 0;
      display: block;
      contain: strict;
      container-type: size;
      font-style: normal;
      text-align: center;
      /* no crest where mod() is missing (the rule below): the server-drawn contours hold still */
      --ma-gf-w: 0;
      /* --ma-gf-s is the swish lift, written inline per cell by the listener; it transitions both ways so the halo blooms and releases in steps */
      --ma-gf-v: clamp(1, var(--ma-gf-g) + var(--ma-gf-w) + var(--ma-gf-h) + var(--ma-gf-s), 8);
      counter-set: ma-g var(--ma-gf-v); /* counter-set: WebKit repaints siblings' counters only through this (see SplitFlap) */
      /* every cell has its own colour: hue turns along the diagonal, lightness and chroma rise with density */
      color: oklch(
        calc(var(--ma-gf-l) + var(--ma-gf-v) * var(--ma-gf-lv)) calc(var(--ma-gf-chroma, 0.14) + var(--ma-gf-v) * 0.012)
          calc(var(--ma-gf-hue, 68deg) - (var(--ma-gf-x) + var(--ma-gf-y)) * var(--ma-gf-spread, 3deg))
      );
      transition:
        --ma-gf-h var(--ma-gf-fade, 900ms) linear,
        --ma-gf-s 180ms linear;
    }
    /* wave crest at this cell: distance from the phase, folded by mod(), its size max(d, -d) and clipped by max() — integer
       maths, no per-cell animation. Not abs(): that arrived in Chrome 138, mod() in 125. Without mod() the declaration
       would be kept and invalid at computed time, which took --ma-gf-v to its initial 1 and every cell to one dot */
    @supports (z-index: mod(3, 2)) {
      :where(.ma-gf > b > i) {
        --ma-gf-d: calc(mod(var(--ma-gf-x) - var(--ma-gf-p) + 96, 16) - 8);
        --ma-gf-e: calc(mod(var(--ma-gf-x) + var(--ma-gf-q) + 96, 24) - 12);
        --ma-gf-w: calc(max(0, var(--ma-gf-amp, 3) - max(var(--ma-gf-d), -1 * var(--ma-gf-d))) + max(0, var(--ma-gf-amp, 3) - 1 - max(var(--ma-gf-e), -1 * var(--ma-gf-e))));
      }
    }
    :where(.ma-gf > b > i)::before {
      content: counter(ma-g, ma-glyphs-braille);
      display: block;
      font-size: calc(min(100cqw, 100cqh) * 0.82);
      line-height: 100cqh;
    }
    :where(.ma-gf[data-glyphs='ascii'] > b > i)::before {
      content: counter(ma-g, ma-glyphs-ascii);
    }
    :where(.ma-gf[data-glyphs='blocks'] > b > i)::before {
      content: counter(ma-g, ma-glyphs-blocks);
    }
    /* narrow containers (a phone, a small tile) show every third column, so a glyph never drops below what the eye reads as a character */
    @container (max-width: 40rem) {
      :where(.ma-gf > b > i:not(:nth-child(3n + 1))) {
        display: none;
      }
    }

    /* the trail: lit at once, released one glyph at a time; two neighbours each side so a fast cursor never leaves single marks */
    @media (hover: hover) {
      :where(.ma-gf > b > i:hover) {
        --ma-gf-h: 7;
        transition-duration: 0ms;
      }
      :where(.ma-gf > b > i:hover + i),
      :where(.ma-gf > b > i:has(+ i:hover)) {
        --ma-gf-h: 4;
        transition-duration: 60ms;
      }
      :where(.ma-gf > b > i:hover + i + i),
      :where(.ma-gf > b > i:has(+ i + i:hover)) {
        --ma-gf-h: 2;
        transition-duration: 120ms;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      :where(.ma-gf[data-wave] > b) {
        animation: none;
      }
      :where(.ma-gf[data-glow])::before {
        animation: none;
        will-change: auto;
      }
      :where(.ma-gf > b > i) {
        transition: none;
      }
    }
  }

  @keyframes ma-gf-wave {
    from {
      --ma-gf-p: 0;
    }
    to {
      --ma-gf-p: 16;
    }
  }
  @keyframes ma-gf-wave-2 {
    from {
      --ma-gf-q: 0;
    }
    to {
      --ma-gf-q: 24;
    }
  }
  @keyframes ma-gf-glow {
    from {
      translate: -6% -8%;
    }
    50% {
      translate: 8% 5%;
    }
    to {
      translate: -3% 10%;
    }
  }
</style>

```
