Drifting light rendered as text characters, from a dot in the shadows to a solid glyph in the highlights, brightening under the cursor.
Plain text, reimagined
Customize
Usage
import { AsciiField } from "@/components/motionkit/ascii-field";
<AsciiField className="h-[32rem] rounded-3xl">
<h1>Your hero</h1>
</AsciiField>Motion
The exact curves this component runs, plotted with Motion's own maths.
Drift
Runs on its own
Developer tools, terminals, creative coding and retro-themed sites, and heroes that want texture made of type.
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.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://www.motionkitui.com/r/ascii-field.jsonnpm install motionSave as components/motionkit/ascii-field.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>
);
}
| 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. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://www.motionkitui.com/r/ascii-field.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, it draws one still frame. It also stops drawing off screen.
Yes. Pass any string as ramp, ordered from darkest to brightest, such as " ░▒▓█" for block shading or " 01" for binary.
Dither Gradient
Drifting pools of light rendered through an ordered Bayer dither, like an old print or early Mac screen, with a cursor glow.
Flicker Grid
A field of tiny squares that flicker softly, drawn on a canvas so it stays light.
Cursor Trail
A soft ribbon of light behind the pointer, across the window or inside one section.