# ASCII Field

> Drifting light rendered as text characters, from a dot in the shadows to a solid glyph in the highlights, brightening under the cursor.

Category: Backgrounds · Free · Updated 2026-10-30 · Live demo: https://www.motionkitui.com/components/ascii-field

## When to use it

Developer tools, terminals, creative coding and retro-themed sites, and heroes that want texture made of type.

## How it works

The canvas is divided into character cells. For each one, a few slow interfering sine waves give a brightness, lowered towards the middle for your content and raised near the pointer. The brightness picks a character from the ramp, from a dot to a solid glyph, and sets its opacity. It redraws thirty times a second, which is plenty for text, and stops off screen.

## Installation

```bash
npx shadcn@latest add https://www.motionkitui.com/r/ascii-field.json
```

Or copy `components/motionkit/ascii-field.tsx` below. Requires react and Tailwind CSS v4. Free for personal projects; commercial use needs MotionKit Plus (https://www.motionkitui.com/pricing).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color` | `string` | `"#5FAEB4"` | Character colour. |
| `ramp` | `string` | `" .:-=+*#%@"` | Characters from darkest to brightest. |
| `cell` | `number` | `12` | Character cell size, in pixels. |
| `speed` | `number` | `1` | Drift speed. 0 holds it still. |
| `interactive` | `boolean` | `true` | Brighten characters around the pointer. |
| `children` | `ReactNode` |  | Your content, layered above. |

## Accessibility

- **Keyboard: Not applicable.** Decorative, nothing to focus.
- **Screen readers: Pass.** The canvas is hidden from assistive tech, so the characters are never read out.
- **Reduced motion: Pass.** With reduced motion on, it draws one still frame. It also stops drawing off screen.
- **Colour contrast: Pass.** The field is quieter through the middle, so ivory text there measures 9.8:1 or better.

## Source

```tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";

type AsciiFieldProps = {
  children?: ReactNode;
  /** Colour of the characters. */
  color?: string;
  /** Characters from darkest to brightest. */
  ramp?: string;
  /** Size of each character cell, in pixels. */
  cell?: number;
  /** Drift speed. 0 holds it still. */
  speed?: number;
  /** Brighten the characters around the pointer. */
  interactive?: boolean;
  className?: string;
};

function toRgb(hex: string): [number, number, number] {
  const value = hex.replace("#", "");
  const full = value.length === 3 ? [...value].map((c) => c + c).join("") : value;
  const number = parseInt(full, 16);
  return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
}

/**
 * Drifting light rendered as text: every cell picks a character by brightness, from a dot in the shadows to a
 * solid glyph in the highlights, like ASCII art in motion. The pointer lights up the characters around it.
 */
export function AsciiField({ children, color = "#5FAEB4", ramp = " .:-=+*#%@", cell = 12, speed = 1, interactive = true, className = "" }: AsciiFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    const area = canvas?.parentElement;
    if (!canvas || !context || !area) return;
    const [r, g, b] = toRgb(color);
    const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let width = 0;
    let height = 0;
    let frame = 0;
    let visible = true;
    let elapsed = 0;
    let last = 0;
    let lastDraw = 0;
    const pointer = { x: 0, y: 0, strength: 0, target: 0 };

    function resize() {
      const rect = canvas!.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas!.width = width * ratio;
      canvas!.height = height * ratio;
      context!.setTransform(ratio, 0, 0, ratio, 0, 0);
    }

    function draw(time: number) {
      elapsed += last ? Math.min(0.05, (time - last) / 1000) * speed : 0;
      last = time;
      pointer.strength += (pointer.target - pointer.strength) * 0.1;
      const t = elapsed * 0.4;
      context!.clearRect(0, 0, width, height);
      context!.font = `${Math.round(cell * 0.95)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
      context!.textAlign = "center";
      context!.textBaseline = "middle";
      const columns = Math.ceil(width / (cell * 0.62));
      const rows = Math.ceil(height / cell);
      const stepX = width / columns;
      for (let row = 0; row < rows; row++) {
        const v = row / rows;
        for (let column = 0; column < columns; column++) {
          const u = column / columns;
          // a few slow, interfering waves: soft bands of light that drift and fold
          let light = 0.5 + 0.28 * Math.sin(u * 5.5 + t) * Math.cos(v * 4.2 - t * 0.7) + 0.22 * Math.sin((u + v) * 7 - t * 1.3);
          // quieter through the middle, where your content sits
          const cx = u - 0.5;
          const cy = v - 0.5;
          light *= 0.35 + 0.65 * Math.min(1, (cx * cx + cy * cy) * 4);
          if (pointer.strength > 0.01) {
            const dx = column * stepX - pointer.x;
            const dy = row * cell - pointer.y;
            light += Math.exp(-(dx * dx + dy * dy) / (2 * 70 * 70)) * pointer.strength;
          }
          const level = Math.min(ramp.length - 1, Math.max(0, Math.floor(light * ramp.length)));
          if (ramp[level] === " ") continue;
          context!.fillStyle = `rgb(${r} ${g} ${b} / ${0.18 + Math.min(1, light) * 0.6})`;
          context!.fillText(ramp[level], column * stepX + stepX / 2, row * cell + cell / 2);
        }
      }
    }

    // thirty frames a second is plenty for text and halves the work
    function loop(time: number) {
      if (time - lastDraw > 32) {
        draw(time);
        lastDraw = time;
      }
      frame = visible ? requestAnimationFrame(loop) : 0;
      if (!frame) last = 0;
    }

    resize();
    draw(0);
    if (!still) frame = requestAnimationFrame(loop);
    const resizeObserver = new ResizeObserver(() => {
      resize();
      draw(performance.now());
    });
    resizeObserver.observe(canvas);
    const visibilityObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible && !frame && !still) frame = requestAnimationFrame(loop);
    });
    visibilityObserver.observe(canvas);

    function move(event: PointerEvent) {
      if (!interactive || still || event.pointerType === "touch") return;
      const rect = canvas!.getBoundingClientRect();
      pointer.x = event.clientX - rect.left;
      pointer.y = event.clientY - rect.top;
      pointer.target = 1;
    }
    const leave = () => (pointer.target = 0);
    area.addEventListener("pointermove", move);
    area.addEventListener("pointerleave", leave);

    return () => {
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      visibilityObserver.disconnect();
      area.removeEventListener("pointermove", move);
      area.removeEventListener("pointerleave", leave);
    };
  }, [color, ramp, cell, speed, interactive]);

  return (
    <div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
      <canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
      {children}
    </div>
  );
}
```
