AsciiText
PreviewCode
// preview
ASCII
Text
Text color
#fdf9f3
Pointer tilt
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/AsciiText-TS-TW.json"Install the AsciiText component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/AsciiText-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* AsciiText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/ascii-text
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface AsciiTextProps {
text: string;
/** ASCII cell size (px). Smaller cells = finer grid, more glyphs. */
cellSize?: number;
/** Monospace font family for the ASCII glyphs. Defaults to the platform's
* sans-serif mono (SF Mono, Cascadia, Menlo...), never Courier. */
fontFamily?: string;
/** Font family the source word is rasterized with; a heavy sans gives the
* glyph field a bold, readable silhouette. */
textFontFamily?: string;
/** Glyph ramp ordered from lightest to densest coverage. The default ends
* in solid blocks so bright cells read as solid fills. */
charset?: string;
/** Opacity of the solid pixel-block underlay of the word (0-1). The
* ASCII layer is difference-blended over it, so glyphs punch inverted
* color out of the blocks. 0 disables the underlay. */
blocks?: number;
/** Fraction of the container height the text fills (0-1). */
fill?: number;
/** Color of the text. The ASCII glyphs difference-blend over it, and
* hue drift and aberration add the color play, so a near-white works
* best (the reference uses the same warm off-white). */
color?: string;
/** Liquid wave displacement amplitude in cells. 0 renders the text rigid. */
waveAmplitude?: number;
/** Wave speed multiplier. */
waveSpeed?: number;
/** Per-cell brightness twinkle that makes the surface glitter (0-1). */
shimmer?: number;
/** Tilt the text plane toward the pointer while it is over the component. */
interactive?: boolean;
/** Maximum pointer tilt of the 3D plane, in degrees. */
tiltMax?: number;
/** Idle 3D sway of the plane when the pointer is away, in degrees. */
swayMax?: number;
/** RGB channel separation at the glyph edges, in pixels. 0 disables. */
aberration?: number;
/** Amplitude of the slow, pointer-biased hue rotation, in degrees. 0 disables. */
hueDrift?: number;
className?: string;
style?: CSSProperties;
}
// Long luminance ramp (same family as the classic ASCII-shader ramps)
// capped with shade and block glyphs, so the brightest cells render as
// solid fills instead of topping out at "@".
const DEFAULT_CHARSET =
" .'\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$▒▓█";
// Sans-serif monospace stack; bare "monospace" resolves to Courier (a
// serif slab) on most platforms and muddies the glyph field.
const DEFAULT_FONT_FAMILY =
'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, "DejaVu Sans Mono", monospace';
const DEFAULT_TEXT_FONT_FAMILY = 'ui-sans-serif, system-ui, "Helvetica Neue", Arial, sans-serif';
// Same deliberate warm off-white as the reference's textColor.
const DEFAULT_COLOR = "#fdf9f3";
const CHANNELS = ["#ff0000", "#00ff00", "#0000ff"] as const;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);
/** Cheap deterministic per-cell noise in [0, 1). */
const cellNoise = (x: number, y: number) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
};
/**
* Text rendered as a lit 3D plane of ASCII glyphs on a single 2D canvas.
* The word is drawn once into a low-res coverage buffer; every frame,
* each grid cell casts a ray through a perspective camera onto the
* tilting plane (pointer tilt + idle sway), offsets the hit by a liquid
* wave, and samples the buffer. A moving sheen, wave-slope shading, and
* per-cell twinkle grade the brightness so the whole charset ramp is
* exercised, then a pre-rendered glyph atlas is stamped via drawImage
* (no per-frame DOM or fillText). The glyphs are difference-blended
* over a solid pixel-block underlay of the word (drawn from the same
* projected samples, so both layers distort in lockstep), punching
* inverted color out of the flat, deliberately-colored blocks.
* Optional RGB channel separation and a slow pointer-biased hue drift
* finish the frame.
* The real text stays in the DOM for SEO and screen readers, the loop
* parks offscreen, and prefers-reduced-motion gets a static frame.
* Zero dependencies.
*/
export default function AsciiText({
text,
cellSize = 10,
fontFamily = DEFAULT_FONT_FAMILY,
textFontFamily = DEFAULT_TEXT_FONT_FAMILY,
charset = DEFAULT_CHARSET,
blocks = 1,
fill = 0.72,
color = DEFAULT_COLOR,
waveAmplitude = 1.1,
waveSpeed = 1,
shimmer = 0.25,
interactive = true,
tiltMax = 16,
swayMax = 5,
aberration = 1.5,
hueDrift = 25,
className = "",
style,
}: AsciiTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas || !text) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const glyphs = [...charset];
const dpr = Math.min(window.devicePixelRatio || 1, 2);
// Layer stack, mirroring the reference's canvas-under-pre structure
// but composited inside one visible canvas:
// blockCanvas: cols x rows pixel buffer of the sampled word, drawn
// scaled up with smoothing off = solid cell-sized blocks.
// glyphLayer: the white ASCII glyphs, difference-blended over the
// gradient-colored blocks so characters punch inverted color out.
// scene: the composed frame; the visible canvas only receives
// full-frame draws (plain, or per-RGB-channel for aberration).
const scene = document.createElement("canvas");
const sceneCtx = scene.getContext("2d");
const glyphLayer = document.createElement("canvas");
const glyphCtx = glyphLayer.getContext("2d");
const blockCanvas = document.createElement("canvas");
const blockCtx = blockCanvas.getContext("2d");
const channel = document.createElement("canvas");
const channelCtx = channel.getContext("2d");
if (!sceneCtx || !glyphCtx || !blockCtx || !channelCtx) return;
let blockImage: ImageData | null = null;
let cols = 0;
let rows = 0;
let cellW = 0; // device px
let cellH = 0; // device px
let coverage: Float32Array = new Float32Array(0);
let atlas: HTMLCanvasElement | null = null;
let frame = 0;
let visible = true;
let disposed = false;
const start = performance.now();
// Pointer tilt state, normalized to [-1, 1] and eased per frame.
const tilt = { x: 0, y: 0 };
const tiltTarget = { x: 0, y: 0 };
let pointerOver = false;
// Stamp every charset glyph once into an offscreen atlas so the hot
// loop is drawImage-only (fillText per cell per frame is far slower).
const buildAtlas = () => {
atlas = document.createElement("canvas");
atlas.width = Math.max(1, cellW * glyphs.length);
atlas.height = Math.max(1, cellH);
const actx = atlas.getContext("2d");
if (!actx) return;
actx.fillStyle = "#fff";
actx.font = `${cellSize * dpr}px ${fontFamily}`;
actx.textAlign = "center";
actx.textBaseline = "middle";
for (let i = 0; i < glyphs.length; i++) {
actx.fillText(glyphs[i], i * cellW + cellW / 2, cellH / 2);
}
};
// Draw the text once into a cols x rows buffer; its alpha channel is
// the coverage field the projection samples every frame.
const buildCoverage = () => {
const buffer = document.createElement("canvas");
buffer.width = Math.max(1, cols);
buffer.height = Math.max(1, rows);
const bctx = buffer.getContext("2d", { willReadFrequently: true });
if (!bctx) return;
let fontSize = Math.max(4, rows * clamp01(Math.max(fill, 0.05)));
bctx.font = `800 ${fontSize}px ${textFontFamily}`;
const measured = bctx.measureText(text).width;
const maxWidth = cols * 0.9;
if (measured > maxWidth && measured > 0) {
fontSize = Math.max(4, (fontSize * maxWidth) / measured);
}
bctx.font = `800 ${fontSize}px ${textFontFamily}`;
bctx.textAlign = "center";
bctx.textBaseline = "middle";
bctx.fillStyle = "#fff";
bctx.fillText(text, cols / 2, rows / 2);
const data = bctx.getImageData(0, 0, buffer.width, buffer.height).data;
coverage = new Float32Array(buffer.width * buffer.height);
for (let i = 0; i < coverage.length; i++) {
coverage[i] = data[i * 4 + 3] / 255;
}
};
const layout = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
scene.width = canvas.width;
scene.height = canvas.height;
glyphLayer.width = canvas.width;
glyphLayer.height = canvas.height;
channel.width = canvas.width;
channel.height = canvas.height;
// Measure the real advance width of the mono font instead of
// assuming an aspect ratio.
ctx.font = `${cellSize * dpr}px ${fontFamily}`;
const advance = ctx.measureText("M").width || cellSize * dpr * 0.6;
cellW = Math.max(1, Math.round(advance));
cellH = Math.max(1, Math.round(cellSize * dpr));
cols = Math.max(1, Math.floor(canvas.width / cellW));
rows = Math.max(1, Math.floor(canvas.height / cellH));
blockCanvas.width = cols;
blockCanvas.height = rows;
blockImage = blockCtx.createImageData(cols, rows);
buildAtlas();
buildCoverage();
};
const render = (now: number) => {
if (!atlas) return;
const t = ((now - start) / 1000) * waveSpeed;
tilt.x = lerp(tilt.x, tiltTarget.x, 0.06);
tilt.y = lerp(tilt.y, tiltTarget.y, 0.06);
const amp = reducedMotion ? 0 : waveAmplitude;
const glitter = reducedMotion ? 0 : clamp01(shimmer);
const maxIdx = glyphs.length - 1;
const offsetX = (canvas.width - cols * cellW) / 2;
const offsetY = (canvas.height - rows * cellH) / 2;
// Plane orientation: pointer tilt plus an idle lissajous sway so
// the plane keeps breathing when the pointer is away.
const sway = reducedMotion ? 0 : (swayMax * Math.PI) / 180;
const ax =
tilt.y * ((tiltMax * Math.PI) / 180) + Math.sin(t * 0.33) * sway * (pointerOver ? 0.3 : 1);
const ay =
-tilt.x * ((tiltMax * Math.PI) / 180) + Math.cos(t * 0.27) * sway * (pointerOver ? 0.3 : 1);
// Rotation basis vectors of the plane (Rx * Ry applied to the
// plane's local axes), everything in cell units.
const cx = Math.cos(ax);
const sx = Math.sin(ax);
const cy = Math.cos(ay);
const sy = Math.sin(ay);
// e1 = rotated X axis, e2 = rotated Y axis, n = rotated normal.
const e1 = { x: cy, y: 0, z: -sy };
const e2 = { x: sx * sy, y: cx, z: sx * cy };
const n = { x: cx * sy, y: -sx, z: cx * cy };
// Perspective camera on the +Z axis looking at the plane origin.
const camZ = Math.max(cols, rows) * 2.1;
const focal = camZ;
const camDotN = camZ * n.z;
sceneCtx.clearRect(0, 0, scene.width, scene.height);
glyphCtx.clearRect(0, 0, glyphLayer.width, glyphLayer.height);
const blocksAmount = clamp01(blocks);
const pixels = blockImage ? blockImage.data : null;
if (pixels) pixels.fill(0);
for (let gy = 0; gy < rows; gy++) {
const py = gy - rows / 2 + 0.5;
for (let gx = 0; gx < cols; gx++) {
const px = gx - cols / 2 + 0.5;
// Cast a ray from the camera through this cell and intersect
// the tilted plane, then read the hit in plane coordinates.
const dz = -focal;
const denom = px * n.x + py * n.y + dz * n.z;
if (Math.abs(denom) < 1e-6) continue;
const hit = -camDotN / denom;
if (hit <= 0) continue;
const wx = px * hit;
const wy = py * hit;
const wz = camZ + dz * hit;
let u = wx * e1.x + wy * e1.y + wz * e1.z + cols / 2;
let v = wx * e2.x + wy * e2.y + wz * e2.z + rows / 2;
// Liquid wave displacement in plane space.
const waveU = Math.sin(t * 1.1 + v * 0.35);
const waveV = Math.cos(t * 0.9 + u * 0.22);
u += waveU * amp;
v += waveV * amp * 0.6;
// Cell centers sit at +0.5, so flooring maps the hit back to
// its cell index without a half-cell bias.
const su = Math.floor(u);
const sv = Math.floor(v);
if (su < 0 || su >= cols || sv < 0 || sv >= rows) continue;
const cover = coverage[sv * cols + su];
if (cover < 0.04) continue;
// The block underlay samples through the same projected, wave
// displaced coordinate, so both layers distort in lockstep,
// exactly like the reference's canvas and its ASCII overlay
// showing the same frame.
if (pixels) {
const p = (gy * cols + gx) * 4;
pixels[p] = 255;
pixels[p + 1] = 255;
pixels[p + 2] = 255;
pixels[p + 3] = Math.round(cover * 255);
}
// Lighting: a sheen band sweeping diagonally across the plane,
// shading from the wave slope, and a per-cell twinkle. This is
// what spreads cells across the whole glyph ramp instead of
// clumping them at the densest character.
const sheen = 0.5 + 0.5 * Math.sin(u * 0.16 + v * 0.24 - t * 1.5);
const slope = 0.5 + 0.5 * (waveU * 0.7 + waveV * 0.3);
let brightness = 0.34 + 0.42 * sheen + 0.24 * slope;
if (glitter > 0) {
const twinkle = Math.sin(t * 3 + cellNoise(su, sv) * Math.PI * 8);
brightness *= 1 - glitter * 0.4 * (0.5 + 0.5 * twinkle);
}
brightness *= cover;
// Gamma lift pushes midtones toward the dense end of the ramp,
// so word cores read as near-solid blocks while edges and
// shadows fall through the lighter glyphs.
const idx = Math.min(maxIdx, Math.max(1, Math.round(Math.pow(brightness, 0.7) * maxIdx)));
glyphCtx.drawImage(
atlas,
idx * cellW,
0,
cellW,
cellH,
offsetX + gx * cellW,
offsetY + gy * cellH,
cellW,
cellH,
);
}
}
// Paint the text color through a layer's alpha in a single pass.
const paintColor = (target: CanvasRenderingContext2D, w: number, h: number) => {
target.globalCompositeOperation = "source-in";
target.fillStyle = color;
target.fillRect(0, 0, w, h);
target.globalCompositeOperation = "source-over";
};
if (blocksAmount > 0 && blockImage) {
// Solid pixel blocks: the low-res buffer scaled up with
// smoothing off (canvas equivalent of image-rendering: pixelated),
// filled with the text color. The white ASCII glyphs then land on
// top with a difference blend, punching inverted color out of
// the blocks, the reference's mix-blend-mode: difference look.
blockCtx.putImageData(blockImage, 0, 0);
sceneCtx.save();
sceneCtx.imageSmoothingEnabled = false;
sceneCtx.globalAlpha = blocksAmount;
sceneCtx.drawImage(blockCanvas, offsetX, offsetY, cols * cellW, rows * cellH);
sceneCtx.restore();
paintColor(sceneCtx, scene.width, scene.height);
sceneCtx.globalCompositeOperation = "difference";
sceneCtx.drawImage(glyphLayer, 0, 0);
sceneCtx.globalCompositeOperation = "source-over";
} else {
// No underlay: tint the glyphs themselves.
paintColor(glyphCtx, glyphLayer.width, glyphLayer.height);
sceneCtx.drawImage(glyphLayer, 0, 0);
}
// Composite to the visible canvas, optionally splitting the RGB
// channels apart for a prismatic fringe (three full-frame draws,
// GPU-composited, no pixel readback).
ctx.clearRect(0, 0, canvas.width, canvas.height);
const split = aberration * dpr;
if (split > 0.05) {
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < CHANNELS.length; i++) {
channelCtx.globalCompositeOperation = "source-over";
channelCtx.clearRect(0, 0, channel.width, channel.height);
channelCtx.drawImage(scene, 0, 0);
channelCtx.globalCompositeOperation = "multiply";
channelCtx.fillStyle = CHANNELS[i];
channelCtx.fillRect(0, 0, channel.width, channel.height);
channelCtx.globalCompositeOperation = "destination-in";
channelCtx.drawImage(scene, 0, 0);
const shift = (i - 1) * split * (0.6 + 0.4 * Math.abs(tilt.x + tilt.y));
ctx.drawImage(channel, shift, 0);
}
ctx.globalCompositeOperation = "source-over";
} else {
ctx.drawImage(scene, 0, 0);
}
// Slow hue orbit, biased by the pointer, one cheap style write.
if (hueDrift > 0 && !reducedMotion) {
const hue = hueDrift * (0.6 * Math.sin(t * 0.24) + 0.4 * (tilt.x + tilt.y));
canvas.style.filter = `hue-rotate(${hue.toFixed(1)}deg)`;
}
};
// Reduced motion renders one static frame; everything else animates.
const isStatic = reducedMotion;
const loop = (now: number) => {
if (disposed) return;
render(now);
if (!isStatic && visible) frame = requestAnimationFrame(loop);
};
const wake = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(loop);
};
layout();
wake();
// Re-render once the mono font actually loads, glyph metrics change.
document.fonts.ready.then(() => {
if (!disposed) {
layout();
if (isStatic) wake();
}
});
const resizeObserver = new ResizeObserver(() => {
layout();
if (isStatic) wake();
});
resizeObserver.observe(container);
// Park the rAF loop entirely while the component is offscreen.
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !isStatic) wake();
});
intersectionObserver.observe(container);
const onPointerMove = (event: PointerEvent) => {
const rect = container.getBoundingClientRect();
pointerOver = true;
tiltTarget.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
tiltTarget.y = ((event.clientY - rect.top) / rect.height) * 2 - 1;
};
const onPointerLeave = () => {
pointerOver = false;
tiltTarget.x = 0;
tiltTarget.y = 0;
};
if (interactive && !reducedMotion) {
container.addEventListener("pointermove", onPointerMove, { passive: true });
container.addEventListener("pointerleave", onPointerLeave);
}
return () => {
disposed = true;
cancelAnimationFrame(frame);
resizeObserver.disconnect();
intersectionObserver.disconnect();
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerleave", onPointerLeave);
};
}, [
text,
cellSize,
fontFamily,
textFontFamily,
charset,
blocks,
fill,
color,
waveAmplitude,
waveSpeed,
shimmer,
interactive,
tiltMax,
swayMax,
aberration,
hueDrift,
]);
return (
<div ref={containerRef} className={`relative h-full w-full ${className}`} style={style}>
<canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
<span className="sr-only">{text}</span>
</div>
);
}
// props
Need the license details? Read the component license.