shadimage
morphiq
componentascii-fieldsource: ok · hit

ASCII Field

A canvas field of glyphs that scramble around the cursor and settle back to rest.

npx shadcn add "https://morphiq.prathampatel.design/r/ascii-field.json"
free

Interaction

cursor-follow

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationgsap

Files4 · source cached 10/15/2026, 7:39:46 AM

src/components/AsciiField/index.js
export { default } from './AsciiField.jsx'
src/components/AsciiField/AsciiField.jsx
'use client'

import { useRef, useEffect } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import './AsciiField.css'

gsap.registerPlugin(useGSAP)

/* =========================================================
   AsciiField
   A full-screen grid of ASCII glyphs, all resting at "0".
   A circular cursor zone scrambles the characters: fastest at
   the exact center of the cursor, slowing toward the
   circumference, frozen outside the cursorRadius, and snapping back
   to "0" the moment the cursor leaves.

   Rendering is speed-proof: a static field of "0"s is
   pre-rendered to an offscreen canvas and blitted in full every
   frame (erasing the previous scramble wherever it was), then
   only the glyphs inside the current circle are drawn on top —
   so nothing can tear no matter how fast the pointer moves.

   The loop runs on gsap.ticker; the cursor is smoothed with
   gsap.quickTo. Radius / speed / color are live-tunable via the
   control panel.
   ========================================================= */

// Printable ASCII glyphs (33..126) — the pool every cell samples from.
const GLYPHS = Array.from({ length: 126 - 33 + 1 }, (_, i) =>
  String.fromCharCode(33 + i)
)
const REST = 255 // sentinel index meaning "resting" — drawn as the typed rest char

const BASE_CELL_W = 9   // base cell width  — the aspect ratio the spacing keeps
const BASE_CELL_H = 16  // base cell height — 9:16, scaled together by "spacing"
const FONT_SIZE = 15    // px monospace glyph size (fixed — spacing widens the gaps)
const FALLOFF = 1.7     // >1 concentrates scramble speed toward the center
const FONT = `${FONT_SIZE}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`
const BG = '#0a0b0e'   // default; the `background` prop overrides it

/**
 * A canvas field of glyphs that scramble around the cursor and settle back
 * to rest. Fully controlled — every knob is a prop with a default.
 *
 *   <AsciiField cursorRadius={170} speed={26} opacity={0.4} spacing={1} />
 */
export default function AsciiField({
  cursorRadius = 170,          // cursor influence, px
  speed = 26,            // glyph changes per second at the centre
  color = '#ffffff',     // glyph colour
  opacity = 0.4,         // resting baseline opacity
  spacing = 1,           // cell scale, keeps the 9:16 ratio
  rest = '0',            // the resting glyph; '' leaves cells empty
  background = BG,
  className = '',
  style,
  ...rest_
}) {
  const canvasRef = useRef(null)
  const rootRef = useRef(null)

  /* Live parameters the render loop reads each frame without re-running setup.
     Synced in an effect declared ahead of the two below, so a rebuild or a
     baseline repaint triggered by the same render already sees the new values. */
  const params = useRef({ cursorRadius, speed, color, opacity, spacing, rest, background })
  useEffect(() => {
    params.current = { cursorRadius, speed, color, opacity, spacing, rest, background }
  })

  /* Repaint the baseline when anything baked into it changes. The background
     is part of the glyph sprite atlas, so a colour change has to rebuild
     rather than wait for cells to turn over. */
  const redrawStatic = useRef(() => {})
  useEffect(() => {
    redrawStatic.current()
  }, [color, opacity, rest])

  // Rebuild the whole grid (cell size, buffers, baseline) when spacing changes.
  const rebuild = useRef(() => {})
  useEffect(() => {
    rebuild.current()
  }, [spacing, background])

  useGSAP(
    () => {
      const canvas = canvasRef.current
      const ctx = canvas.getContext('2d')

      // Offscreen baseline: the whole field rendered as "0"s.
      const staticCanvas = document.createElement('canvas')
      const sctx = staticCanvas.getContext('2d')

      const cursor = { x: -9999, y: -9999 }
      const xTo = gsap.quickTo(cursor, 'x', { duration: 0.2, ease: 'power3' })
      const yTo = gsap.quickTo(cursor, 'y', { duration: 0.2, ease: 'power3' })

      let cols = 0
      let rows = 0
      let w = 0
      let h = 0
      let dpr = 1
      let cellW = BASE_CELL_W
      let cellH = BASE_CELL_H
      let glyphIdx = new Uint8Array(0)
      let accum = new Float32Array(0)

      // A random glyph that is never the current resting char, so the disc
      // never flashes a stray "resting" glyph while it's scrambling.
      const rand = () => {
        const pool = GLYPHS.length
        const g = Math.floor(Math.random() * pool)
        const excl = GLYPHS.indexOf(params.current.rest)
        return g === excl ? (g + 1) % pool : g
      }

      // Paint the static "0" baseline — centered in each cell, dimmed to the
      // chosen opacity so the field can read as sparse instead of "so full".
      const buildStatic = () => {
        sctx.setTransform(dpr, 0, 0, dpr, 0, 0)
        sctx.font = FONT
        sctx.textBaseline = 'middle'
        sctx.textAlign = 'center'
        sctx.fillStyle = params.current.background
        sctx.fillRect(0, 0, w, h)
        sctx.globalAlpha = params.current.opacity
        sctx.fillStyle = params.current.color
        const restChar = params.current.rest || ' '
        for (let row = 0; row < rows; row++) {
          const y = row * cellH + cellH / 2
          for (let col = 0; col < cols; col++) sctx.fillText(restChar, col * cellW + cellW / 2, y)
        }
        sctx.globalAlpha = 1
      }
      redrawStatic.current = buildStatic

      // Size everything to the viewport and rebuild the buffers + baseline.
      const build = () => {
        dpr = Math.min(window.devicePixelRatio || 1, 2)
        w = rootRef.current.clientWidth
        h = rootRef.current.clientHeight

        /* Scale both cell dimensions by the same spacing factor → the gap
           between glyphs grows horizontally and vertically at the 9:16 ratio.
           Floored, because spacing=0 divides to Infinity and `new
           Uint8Array(Infinity)` throws RangeError before anything renders. */
        const sp = Math.max(0.05, params.current.spacing)
        cellW = BASE_CELL_W * sp
        cellH = BASE_CELL_H * sp
        cols = Math.ceil(w / cellW)
        rows = Math.ceil(h / cellH)

        canvas.width = w * dpr
        canvas.height = h * dpr
        canvas.style.width = w + 'px'
        canvas.style.height = h + 'px'
        staticCanvas.width = w * dpr
        staticCanvas.height = h * dpr

        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
        ctx.font = FONT
        ctx.textBaseline = 'middle'
        ctx.textAlign = 'center'

        const count = cols * rows
        glyphIdx = new Uint8Array(count)
        accum = new Float32Array(count)
        glyphIdx.fill(REST)

        buildStatic()
      }
      rebuild.current = build

      const tick = (time, deltaMs) => {
        const dt = Math.min(deltaMs, 50) / 1000
        const { cursorRadius, speed, color } = params.current

        // Blit the full baseline: instantly resets the entire field to "0",
        // erasing wherever last frame's circle was — this is what makes fast
        // movement tear-free.
        ctx.drawImage(staticCanvas, 0, 0, w, h)

        const cx0 = cursor.x
        const cy0 = cursor.y
        const c0 = Math.max(0, Math.floor((cx0 - cursorRadius) / cellW))
        const c1 = Math.min(cols - 1, Math.ceil((cx0 + cursorRadius) / cellW))
        const r0 = Math.max(0, Math.floor((cy0 - cursorRadius) / cellH))
        const r1 = Math.min(rows - 1, Math.ceil((cy0 + cursorRadius) / cellH))
        if (c1 < c0 || r1 < r0) return

        const r2 = cursorRadius * cursorRadius
        for (let row = r0; row <= r1; row++) {
          const cy = row * cellH + cellH / 2
          for (let col = c0; col <= c1; col++) {
            const cx = col * cellW + cellW / 2
            const dx = cx - cx0
            const dy = cy - cy0
            const d2 = dx * dx + dy * dy
            if (d2 >= r2) continue

            const f = Math.pow(1 - Math.sqrt(d2) / cursorRadius, FALLOFF)
            const i = row * cols + col

            // The moment a resting cell enters the disc, activate it with a
            // random glyph. This makes the whole circle scramble on contact —
            // independent of pointer speed — so a fast sweep can no longer
            // leave un-flipped "0"s behind. Re-randomisation speed still falls
            // off from center (fastest) to circumference (slowest).
            if (glyphIdx[i] === REST) glyphIdx[i] = rand()

            accum[i] += speed * f * dt
            if (accum[i] >= 1) {
              glyphIdx[i] = rand()
              accum[i] -= Math.floor(accum[i])
            }

            // Overwrite the baseline "0" with the scrambled glyph (full
            // opacity — the disc stays bright even when the field is dimmed).
            // Cells inside the disc are always non-zero now, so this never
            // re-draws (and blurs) a resting "0" over the crisp baseline.
            ctx.fillStyle = params.current.background
            ctx.fillRect(col * cellW, row * cellH, cellW, cellH)
            ctx.fillStyle = color
            ctx.fillText(GLYPHS[glyphIdx[i]], cx, cy)
          }
        }
      }

      build()

      const onMove = (e) => {
        const rect = rootRef.current.getBoundingClientRect()
        xTo(e.clientX - rect.left)
        yTo(e.clientY - rect.top)
      }
      const onLeave = () => {
        xTo(-9999)
        yTo(-9999)
      }

      /* The field was the one component that ignored reduced motion outright.
         With it set, the static glyph grid is still drawn — the component is
         not blank — but nothing scrambles and the ticker never starts. */
      const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches

      if (!still) {
        window.addEventListener('mousemove', onMove)
        rootRef.current.addEventListener('mouseleave', onLeave)
        gsap.ticker.add(tick)
      }
      window.addEventListener('resize', build)

      return () => {
        window.removeEventListener('mousemove', onMove)
        rootRef.current?.removeEventListener('mouseleave', onLeave)
        window.removeEventListener('resize', build)
        gsap.ticker.remove(tick)
        redrawStatic.current = () => {}
      }
    },
    { scope: rootRef }
  )

  return (
    <div
      className={`ascii-field ${className}`.trim()}
      ref={rootRef}
      style={{ background, ...style }}
      {...rest_}
    >
      {/* Purely decorative: the glyphs carry no meaning to convey. */}
      <canvas className="ascii-field__canvas" ref={canvasRef} aria-hidden="true" />
    </div>
  )
}
src/components/AsciiField/AsciiField.css
.ascii-field {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #0a0b0e;
  cursor: crosshair;
}

.ascii-field__canvas {
  display: block;
  width: 100%;
  height: 100%;
}
src/components/AsciiField/AsciiField.d.ts
import type { ComponentPropsWithoutRef } from 'react'

export interface AsciiFieldProps extends ComponentPropsWithoutRef<'div'> {
  /** Reach of the pointer's influence, in px. */
  cursorRadius?: number
  /** Glyph changes per second at the centre of that reach. */
  speed?: number
  color?: string
  /** Resting baseline opacity, before the cursor touches a cell. */
  opacity?: number
  /** Cell scale, holding the 9:16 ratio. Floored just above zero. */
  spacing?: number
  /** The resting glyph. An empty string leaves cells blank. */
  rest?: string
  background?: string
}

declare const AsciiField: (props: AsciiFieldProps) => JSX.Element
export default AsciiField

More from morphiq

Browse all components in this library →