ASCII Text
3D text rendered to a wavy three.js plane, then re-sampled every frame into live ASCII characters that shimmer and follow your cursor. From B3's ai-arena.
Usage
import { ASCIIText } from "~/components/ascii/ascii-text";// fills its parent — give it a sized, positioned box<div className="relative h-96"><ASCIIText text="hello" enableWaves /></div>
WebGL in, text out
The word is drawn to an offscreen 2D canvas, uploaded as a texture, and mapped onto a subdivided plane. A vertex shader pushes the vertices around on sine waves; a fragment shader splits the RGB channels by a hair so the edges shimmer. Standard three.js so far.
The ASCII step is the fun part. Every frame the rendered WebGL is drawn into a tiny canvas — one pixel per output character — and each pixel's brightness is read back and mapped to a glyph from a ramp that runs dark to light. The string lands in a <pre> with mix-blend-mode: difference and a gradient text fill. Cursor position drives the plane's rotation and a hue shift, so it tracks the mouse without any per-character work.
From B3's ai-arena, where it titled the HypeDuel arena. The original effect is from the React Bits community.
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useEffect, useState, type ComponentType } from "react";import { cn } from "~/lib/utils";export interface ASCIITextProps {text?: string;asciiFontSize?: number;textFontSize?: number;textColor?: string;planeBaseHeight?: number;enableWaves?: boolean;/** CSS background used as the text fill (clipped to the glyphs). */gradient?: string;className?: string;}/*** Client-only loader for the three.js ASCII text scene. The scene statically* imports `three`, which must never run during Cloudflare Workers SSR — so it's* pulled in with a dynamic import() inside useEffect, the same pattern the* infinite-terrain scene uses.*/export function ASCIIText({ className, ...props }: ASCIITextProps) {const [Scene, setScene] = useState<ComponentType<any> | null>(null);useEffect(() => {let alive = true;import("./ascii-text-scene").then((m) => {if (alive) setScene(() => m.default);});return () => {alive = false;};}, []);return (<div className={cn("relative h-full w-full overflow-hidden", className)}>{Scene ? (<Scene {...props} />) : (<div className="absolute inset-0 grid place-items-center font-mono text-xs text-white/30">loading…</div>)}</div>);}
// @ts-nocheck// Vendored from B3's ai-arena (hypeduel) ASCIIText. Renders text to a hidden// canvas, maps it onto a wavy three.js plane, then re-samples the WebGL output// into an ASCII <pre>. Restyled: the fill gradient is a CSS var (`--ascii-grad`)// so the loader can theme it. three only — no r3f. Loaded client-side only.import { useRef, useEffect } from "react";import * as THREE from "three";const vertexShader = `varying vec2 vUv;uniform float uTime;uniform float mouse;uniform float uEnableWaves;void main() {vUv = uv;float time = uTime * 5.;float waveFactor = uEnableWaves;vec3 transformed = position;transformed.x += sin(time + position.y) * 0.5 * waveFactor;transformed.y += cos(time + position.z) * 0.15 * waveFactor;transformed.z += sin(time + position.x) * waveFactor;gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);}`;const fragmentShader = `varying vec2 vUv;uniform float mouse;uniform float uTime;uniform sampler2D uTexture;void main() {float time = uTime;vec2 pos = vUv;float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;float a = texture2D(uTexture, pos).a;gl_FragColor = vec4(r, g, b, a);}`;function map(n, start, stop, start2, stop2) {return ((n - start) / (stop - start)) * (stop2 - start2) + start2;}const PX_RATIO = typeof window !== "undefined" ? window.devicePixelRatio : 1;class AsciiFilter {constructor(renderer, { fontSize, fontFamily, charset, invert } = {}) {this.renderer = renderer;this.domElement = document.createElement("div");this.domElement.style.position = "absolute";this.domElement.style.top = "0";this.domElement.style.left = "0";this.domElement.style.width = "100%";this.domElement.style.height = "100%";this.pre = document.createElement("pre");this.domElement.appendChild(this.pre);this.canvas = document.createElement("canvas");this.context = this.canvas.getContext("2d");this.domElement.appendChild(this.canvas);this.deg = 0;this.invert = invert ?? true;this.fontSize = fontSize ?? 12;this.fontFamily = fontFamily ?? "'Courier New', monospace";this.charset =charset ??" .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";if (this.context) this.context.imageSmoothingEnabled = false;this.onMouseMove = this.onMouseMove.bind(this);document.addEventListener("mousemove", this.onMouseMove);}setSize(width, height) {this.width = width;this.height = height;this.renderer.setSize(width, height);this.reset();this.center = { x: width / 2, y: height / 2 };this.mouse = { x: this.center.x, y: this.center.y };}reset() {if (!this.context) return;this.context.font = `${this.fontSize}px ${this.fontFamily}`;const charWidth = this.context.measureText("A").width;this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)),);this.rows = Math.floor(this.height / this.fontSize);this.canvas.width = this.cols;this.canvas.height = this.rows;this.pre.style.fontFamily = this.fontFamily;this.pre.style.fontSize = `${this.fontSize}px`;this.pre.style.margin = "0";this.pre.style.padding = "0";this.pre.style.lineHeight = "1em";this.pre.style.position = "absolute";this.pre.style.left = "50%";this.pre.style.top = "50%";this.pre.style.transform = "translate(-50%, -50%)";this.pre.style.zIndex = "9";this.pre.style.backgroundAttachment = "fixed";this.pre.style.mixBlendMode = "difference";}render(scene, camera) {this.renderer.render(scene, camera);const w = this.canvas.width;const h = this.canvas.height;if (!this.context) return;this.context.clearRect(0, 0, w, h);if (w && h) this.context.drawImage(this.renderer.domElement, 0, 0, w, h);this.asciify(this.context, w, h);this.hue();}onMouseMove(e) {this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };}get dx() {return this.mouse.x - this.center.x;}get dy() {return this.mouse.y - this.center.y;}hue() {const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;this.deg += (deg - this.deg) * 0.075;this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;}asciify(ctx, w, h) {if (!(w && h)) return;const imgData = ctx.getImageData(0, 0, w, h).data;let str = "";for (let y = 0; y < h; y++) {for (let x = 0; x < w; x++) {const i = x * 4 + y * 4 * w;const r = imgData[i];const g = imgData[i + 1];const b = imgData[i + 2];const a = imgData[i + 3];if (a === 0) {str += " ";continue;}const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;let idx = Math.floor((1 - gray) * (this.charset.length - 1));if (this.invert) idx = this.charset.length - idx - 1;str += this.charset[idx];}str += "\n";}this.pre.innerHTML = str;}dispose() {document.removeEventListener("mousemove", this.onMouseMove);}}class CanvasTxt {constructor(txt, { fontSize = 200, fontFamily = "Arial", color = "#fdf9f3" } = {}) {this.canvas = document.createElement("canvas");this.context = this.canvas.getContext("2d");this.txt = txt;this.fontSize = fontSize;this.fontFamily = fontFamily;this.color = color;this.font = `600 ${this.fontSize}px ${this.fontFamily}`;}resize() {if (!this.context) return;this.context.font = this.font;const metrics = this.context.measureText(this.txt);const textWidth = Math.ceil(metrics.width) + 20;const textHeight =Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,) + 20;this.canvas.width = textWidth;this.canvas.height = textHeight;}render() {if (!this.context) return;this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);this.context.fillStyle = this.color;this.context.font = this.font;const metrics = this.context.measureText(this.txt);const yPos = 10 + metrics.actualBoundingBoxAscent;this.context.fillText(this.txt, 10, yPos);}get width() {return this.canvas.width;}get height() {return this.canvas.height;}get texture() {return this.canvas;}}class CanvAscii {constructor({ text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves },containerElem,width,height,) {this.textString = text;this.asciiFontSize = asciiFontSize;this.textFontSize = textFontSize;this.textColor = textColor;this.planeBaseHeight = planeBaseHeight;this.container = containerElem;this.width = width;this.height = height;this.enableWaves = enableWaves;this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);this.camera.position.z = 30;this.scene = new THREE.Scene();this.mouse = { x: 0, y: 0 };this.animationFrameId = 0;this.onMouseMove = this.onMouseMove.bind(this);this.setMesh();this.setRenderer();}setMesh() {this.textCanvas = new CanvasTxt(this.textString, {fontSize: this.textFontSize,fontFamily: "IBM Plex Mono",color: this.textColor,});this.textCanvas.resize();this.textCanvas.render();this.texture = new THREE.CanvasTexture(this.textCanvas.texture);this.texture.minFilter = THREE.NearestFilter;const textAspect = this.textCanvas.width / this.textCanvas.height;const baseH = this.planeBaseHeight;const planeW = baseH * textAspect;this.geometry = new THREE.PlaneGeometry(planeW, baseH, 36, 36);this.material = new THREE.ShaderMaterial({vertexShader,fragmentShader,transparent: true,uniforms: {uTime: { value: 0 },mouse: { value: 1.0 },uTexture: { value: this.texture },uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 },},});this.mesh = new THREE.Mesh(this.geometry, this.material);this.scene.add(this.mesh);}setRenderer() {this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });this.renderer.setPixelRatio(1);this.renderer.setClearColor(0x000000, 0);this.filter = new AsciiFilter(this.renderer, {fontFamily: "IBM Plex Mono",fontSize: this.asciiFontSize,invert: true,});this.container.appendChild(this.filter.domElement);this.setSize(this.width, this.height);this.container.addEventListener("mousemove", this.onMouseMove);this.container.addEventListener("touchmove", this.onMouseMove);}setSize(w, h) {this.width = w;this.height = h;this.camera.aspect = w / h;this.camera.updateProjectionMatrix();this.filter.setSize(w, h);this.center = { x: w / 2, y: h / 2 };}load() {this.animate();}onMouseMove(evt) {const e = evt.touches ? evt.touches[0] : evt;const bounds = this.container.getBoundingClientRect();this.mouse = { x: e.clientX - bounds.left, y: e.clientY - bounds.top };}animate() {const animateFrame = () => {this.animationFrameId = requestAnimationFrame(animateFrame);this.render();};animateFrame();}render() {const time = new Date().getTime() * 0.001;this.textCanvas.render();this.texture.needsUpdate = true;this.mesh.material.uniforms.uTime.value = Math.sin(time);this.updateRotation();this.filter.render(this.scene, this.camera);}updateRotation() {const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;}clear() {this.scene.traverse((object) => {const obj = object;if (!obj.isMesh) return;[obj.material].flat().forEach((material) => {material.dispose();Object.keys(material).forEach((key) => {const matProp = material[key];if (matProp && typeof matProp === "object" && typeof matProp.dispose === "function") {matProp.dispose();}});});obj.geometry.dispose();});this.scene.clear();}dispose() {cancelAnimationFrame(this.animationFrameId);this.filter.dispose();this.container.removeChild(this.filter.domElement);this.container.removeEventListener("mousemove", this.onMouseMove);this.container.removeEventListener("touchmove", this.onMouseMove);this.clear();this.renderer.dispose();}}export default function ASCIITextScene({text = "hello",asciiFontSize = 8,textFontSize = 200,textColor = "#fdf9f3",planeBaseHeight = 8,enableWaves = true,gradient = "radial-gradient(circle, #7dd3fc 0%, #818cf8 50%, #e879f9 100%)",}) {const containerRef = useRef(null);const asciiRef = useRef(null);useEffect(() => {if (!containerRef.current) return;const opts = { text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves };const { width, height } = containerRef.current.getBoundingClientRect();const start = (w, h) => {asciiRef.current = new CanvAscii(opts, containerRef.current, w, h);asciiRef.current.load();};if (width === 0 || height === 0) {const observer = new IntersectionObserver(([entry]) => {const r = entry.boundingClientRect;if (entry.isIntersecting && r.width > 0 && r.height > 0) {start(r.width, r.height);observer.disconnect();}},{ threshold: 0.1 },);observer.observe(containerRef.current);return () => {observer.disconnect();asciiRef.current?.dispose();};}start(width, height);const ro = new ResizeObserver((entries) => {if (!entries[0] || !asciiRef.current) return;const { width: w, height: h } = entries[0].contentRect;if (w > 0 && h > 0) asciiRef.current.setSize(w, h);});ro.observe(containerRef.current);return () => {ro.disconnect();asciiRef.current?.dispose();};}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves]);return (<divref={containerRef}className="ascii-text-container"style={{ position: "absolute", inset: 0, ["--ascii-grad"]: gradient }}><style>{`@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');.ascii-text-container canvas {position: absolute; left: 0; top: 0; width: 100%; height: 100%;image-rendering: pixelated;}.ascii-text-container pre {margin: 0; padding: 0; line-height: 1em; text-align: left;user-select: none; position: absolute; left: 0; top: 0;background-image: var(--ascii-grad);background-attachment: fixed;-webkit-text-fill-color: transparent;-webkit-background-clip: text;background-clip: text;z-index: 9; mix-blend-mode: difference;}`}</style></div>);}