Skip to content

Components / Pointer

Glyph Field

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.

Live demo

move your cursor · 0 B CSS · swish inlined

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 7.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 125 · Firefox 128 · Safari 17.2; needs property, css-math, counter-style, counter-set, has. 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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import GlyphField from '@moonarc/core/GlyphField';
---
<section class="relative min-h-[70vh]">
  <GlyphField swish />
  <h1 class="relative">Move your cursor. The field is CSS.</h1>
</section>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/glyph-field.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

GlyphField.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>

Props

PropTypeDefaultDescription
colsnumber48Columns, max 96. The cost is HTML: cols × rows elements at about 40 B raw each, 1.3 kB gzip at 48 × 20.
rowsnumber20Rows, max 64.
seednumber7Seed for the server-side value noise. The same seed always draws the same contours, so the HTML is stable across builds.
huenumber68Base hue in oklch degrees at the top-left cell. 68 is amber; 250 is indigo.
spreadnumber3Degrees 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.
chromanumber0.14Chroma of the glyphs. 0.02 reads as ink, 0.2 as neon. Dense glyphs get a little more.
speednumber6Seconds per wave cycle. The second wave takes 2.5× as long and is one step lower, so the crests never line up.
ampnumber3Wave 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 (#).
fadenumber900Milliseconds a hovered cell takes to decay back, one glyph per step, from eight dots down to one.
reachnumber5Swish 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.
wavebooleantrueRun the two waves.
glowbooleantrueThree soft colour fields drift behind the glyphs on the compositor.
swishbooleanfalseInline 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.

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.

Why it is built this way

Replaces: ASCII canvas hero (motion.dev) · ASCIIText / LetterGlitch (React Bits) · canvas character fields. See the migration table.

Ambient gradient field: three soft colour fields drifting at three coprime speeds, a slow hue rotation so the colours themselves move, and a film-grain layer stepped at 12 fps.

every browseralways

A field of cells that light up under the cursor and fade where it has been, with a slow idle sweep when nothing is touched.

every browserpointeralways

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Draws smoke that follows the cursor.

every browserpointeralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown