React Bits
componentASCIIText-TS-CSSsource: ok · hit
ASCIIText
Renders text with an animated ASCII background for a retro feel.
npx shadcn add "https://reactbits.dev/r/ASCIIText-TS-CSS.json"free
Visual style
retro
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightthree@^0.180.0
Files1 · source cached 10/15/2026, 3:26:12 AM
ASCIIText/ASCIIText.tsx
'use client';
import React, { useEffect, useRef, useState } from 'react';
export type ASCIITextProps = {
text?: string;
asciiFontSize?: number;
charset?: string;
fontFamily?: string;
fontWeight?: number | string;
textColor?: string;
colors?: string[];
textScale?: number;
blocks?: number;
waves?: number;
waveSpeed?: number;
chroma?: number;
tilt?: number;
hueShift?: number;
scramble?: number;
clickRipple?: boolean;
intro?: boolean;
idle?: boolean;
interactive?: boolean;
theme?: 'dark' | 'light';
className?: string;
style?: React.CSSProperties;
};
type Ripple = { x: number; y: number; age: number };
type Build = (value: string, family: string, weight: number | string, color: string, characters: string) => void;
const CHARSET = ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
const FONT = "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
const GLYPH_WIDTH = 40;
const GLYPH_HEIGHT = 64;
const TEXT_PX = 256;
const RIPPLES = 4;
const VERTEX = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
#define RIPPLES ${RIPPLES}
uniform sampler2D uText;
uniform sampler2D uGlyphs;
uniform vec2 uSize;
uniform vec2 uCell;
uniform float uGlyphCount;
uniform float uTextAspect;
uniform float uPlaneHeight;
uniform vec2 uTilt;
uniform float uTime;
uniform float uWaves;
uniform float uWaveSpeed;
uniform float uChroma;
uniform float uBlocks;
uniform vec3 uColors[3];
uniform float uHue;
uniform vec2 uPointer;
uniform float uHover;
uniform float uScramble;
uniform vec4 uRipples[RIPPLES];
uniform float uIntro;
uniform float uLight;
uniform float uReady;
out vec4 fragColor;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec3 hueRotate(vec3 color, float angle) {
const vec3 axis = vec3(0.57735);
float c = cos(angle);
return color * c + cross(axis, color) * sin(angle) + axis * dot(axis, color) * (1.0 - c);
}
vec4 scene(vec2 pixel) {
vec2 ndc = vec2(pixel.x / uSize.x * 2.0 - 1.0, 1.0 - pixel.y / uSize.y * 2.0);
float tanHalf = 0.41421356;
vec3 dir = normalize(vec3(ndc.x * tanHalf * uSize.x / uSize.y, ndc.y * tanHalf, -1.0));
vec3 origin = vec3(0.0, 0.0, 30.0);
float cx = cos(uTilt.x);
float sx = sin(uTilt.x);
float cy = cos(uTilt.y);
float sy = sin(uTilt.y);
mat3 rotation = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx) * mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
vec3 normal = rotation * vec3(0.0, 0.0, 1.0);
float facing = dot(dir, normal);
if (abs(facing) < 0.0001) return vec4(0.0);
vec3 hit = origin + dir * (-dot(origin, normal) / facing);
vec3 local = transpose(rotation) * hit;
float planeHeight = 24.8528 * uPlaneHeight;
float planeWidth = planeHeight * uTextAspect;
float wave = 5.0 * sin(uTime * uWaveSpeed);
local.x -= sin(wave + local.y) * 0.5 * uWaves;
local.y -= cos(wave) * 0.15 * uWaves;
local.xy /= 1.0 + sin(wave + local.x) * uWaves / 30.0;
vec2 uv = vec2(local.x / planeWidth + 0.5, 0.5 - local.y / planeHeight);
float t = sin(uTime);
float red = texture(uText, uv + cos(t + uv.x) * 0.01 * uChroma).r;
float green = texture(uText, uv + clamp(tan(uv.x - t * 0.5), -3.0, 3.0) * 0.01 * uChroma).g;
float blue = texture(uText, uv - cos(t * 3.0 + uv.y) * 0.01 * uChroma).b;
vec4 base = texture(uText, uv);
float inside = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
return vec4(red, green, blue, base.a) * inside;
}
void main() {
vec2 pixel = vec2(gl_FragCoord.x, uSize.y - gl_FragCoord.y);
vec2 cell = floor(pixel / uCell);
vec2 local = fract(pixel / uCell);
vec2 center = (cell + 0.5) * uCell;
float tick = floor(uTime * 18.0);
float ring = 0.0;
vec2 push = vec2(0.0);
for (int i = 0; i < RIPPLES; i++) {
vec4 ripple = uRipples[i];
if (ripple.w <= 0.0) continue;
float radius = ripple.z * max(uSize.x, uSize.y) * 0.75;
float gap = abs(distance(center, ripple.xy) - radius);
float band = max(uCell.y * 3.0, 24.0);
float strength = (1.0 - smoothstep(0.0, 1.2, ripple.z)) * (1.0 - smoothstep(0.0, band, gap));
ring = max(ring, strength);
push += normalize(center - ripple.xy + 0.001) * strength * uCell.y * 1.5;
}
vec4 base = scene(center - push);
float content = step(0.02, base.a);
float gray = clamp(dot(base.rgb, vec3(0.3, 0.6, 0.1)), 0.0, 1.0);
float index = content > 0.0 ? max(1.0, floor(gray * (uGlyphCount - 1.0) + 0.5)) : 0.0;
float randomGlyph = 1.0 + floor(hash(cell * 1.7 + tick * 0.37) * (uGlyphCount - 1.0));
float reach = uSize.y * 0.18 * (0.5 + uScramble);
float scramble = uHover * uScramble * (1.0 - smoothstep(reach * 0.35, reach, distance(center, uPointer)));
if (content > 0.0 && hash(cell + tick * 7.13) < scramble * 0.8) index = randomGlyph;
if (hash(cell + tick * 3.1) < ring) index = randomGlyph;
float decoding = step(hash(cell * 0.37 + 2.1), uIntro);
if (content > 0.0 && decoding > 0.0) index = randomGlyph;
float mask = texture(uGlyphs, vec2((index + local.x) / uGlyphCount, local.y)).a;
float glyphAlpha = mask * max(content * (1.0 - decoding * 0.35), ring * 0.75);
float spread = length((pixel - uSize * 0.5) / (uSize * 0.5)) * 0.7071;
vec3 tint = mix(mix(uColors[0], uColors[1], smoothstep(0.0, 0.5, spread)), uColors[2], smoothstep(0.5, 1.0, spread));
float blockAlpha = base.a * uBlocks * (1.0 - uIntro);
vec3 under = base.rgb * blockAlpha;
vec3 color = mix(under, abs(tint - under), glyphAlpha);
float alpha = max(blockAlpha, glyphAlpha) * uReady;
color = clamp(hueRotate(color, uHue), 0.0, 1.0) * uReady;
if (uLight > 0.5) color = vec3(alpha) - color;
fragColor = vec4(color, alpha);
}`;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return null;
};
const toRgb = (color: string): number[] => {
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return [1, 1, 1];
ctx.fillStyle = '#ffffff';
ctx.fillStyle = color;
const value = String(ctx.fillStyle);
if (value.startsWith('#')) {
const hex = parseInt(value.slice(1, 7), 16);
return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255];
}
const parts = value.match(/[\d.]+/g)?.map(Number) ?? [255, 255, 255];
return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
};
const drawText = (text: string, fontFamily: string, fontWeight: number | string, color: string) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const font = `${fontWeight} ${TEXT_PX}px ${fontFamily}`;
ctx.font = font;
const width = Math.max(TEXT_PX * 0.6, ctx.measureText(text || ' ').width);
canvas.width = Math.ceil(width + TEXT_PX * 0.3);
canvas.height = Math.ceil(TEXT_PX * 1.3);
ctx.font = font;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
return canvas;
};
const drawGlyphs = (charset: string, fontFamily: string) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = charset.length * GLYPH_WIDTH;
canvas.height = GLYPH_HEIGHT;
ctx.font = `500 ${GLYPH_HEIGHT * 0.74}px ${fontFamily}`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
Array.from(charset).forEach((char, i) => {
ctx.fillText(char, i * GLYPH_WIDTH + GLYPH_WIDTH / 2, GLYPH_HEIGHT / 2 + GLYPH_HEIGHT * 0.04);
});
return canvas;
};
export default function ASCIIText({
text = 'Hey!',
asciiFontSize = 8,
charset = CHARSET,
fontFamily = FONT,
fontWeight = 600,
textColor = '#fdf9f3',
colors = ['#ff6188', '#fc9867', '#ffd866'],
textScale = 1,
blocks = 0.9,
waves = 1,
waveSpeed = 1,
chroma = 1,
tilt = 1,
hueShift = 1,
scramble = 0.6,
clickRipple = true,
intro = true,
idle = true,
interactive = true,
theme = 'dark',
className = '',
style
}: ASCIITextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const buildRef = useRef<Build | null>(null);
const [fallback, setFallback] = useState(false);
const glyphs = Array.from(charset).length > 1 ? charset : CHARSET;
const palette = (colors?.length ? colors : ['#ffffff']).slice(0, 3);
const settings = {
cellSize: Math.max(3, asciiFontSize),
textScale: clamp(textScale, 0.1, 3),
blocks: clamp(blocks, 0, 1),
waves: Math.max(0, waves),
waveSpeed: Math.max(0, waveSpeed),
chroma: Math.max(0, chroma),
tilt: Math.max(0, tilt),
hueShift: clamp(hueShift, 0, 2),
scramble: clamp(scramble, 0, 1),
clickRipple,
intro,
idle,
interactive,
light: theme === 'light',
colors: [palette[0], palette[1] ?? palette[0], palette[2] ?? palette[1] ?? palette[0]]
};
const settingsRef = useRef(settings);
settingsRef.current = settings;
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return undefined;
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false });
let program: WebGLProgram | null = null;
let buffer: WebGLBuffer | null = null;
let textTexture: WebGLTexture | null = null;
let glyphTexture: WebGLTexture | null = null;
const uniforms: Record<string, WebGLUniformLocation | null> = {};
if (gl) {
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
if (vertex && fragment) {
program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.bindAttribLocation(program, 0, 'position');
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
program = null;
}
}
if (program) {
buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
textTexture = gl.createTexture();
glyphTexture = gl.createTexture();
for (const name of [
'uText',
'uGlyphs',
'uSize',
'uCell',
'uGlyphCount',
'uTextAspect',
'uPlaneHeight',
'uTilt',
'uTime',
'uWaves',
'uWaveSpeed',
'uChroma',
'uBlocks',
'uColors',
'uHue',
'uPointer',
'uHover',
'uScramble',
'uRipples',
'uIntro',
'uLight',
'uReady'
]) {
uniforms[name] = gl.getUniformLocation(program, name);
}
}
}
if (!gl || !program) {
setFallback(true);
return undefined;
}
const upload = (texture: WebGLTexture | null, source: HTMLCanvasElement, smooth: boolean) => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, smooth ? gl.LINEAR_MIPMAP_LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, smooth ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
};
const state = {
ready: false,
textAspect: 1,
glyphCount: 1,
time: 0,
tiltX: 0,
tiltY: 0,
hue: 0,
hover: 0,
intro: 0,
pointerX: 0,
pointerY: 0,
targetX: 0,
targetY: 0,
inside: false,
visible: true,
ripples: [] as Ripple[],
introPlayed: false
};
const rippleData = new Float32Array(RIPPLES * 4);
const colorData = new Float32Array(9);
let colorKey = '';
let raf = 0;
let last = performance.now();
let alive = true;
const build = (
value: string,
family: string,
weight: number | string,
color: string,
characters: string,
playIntro: boolean
) => {
const textCanvas = drawText(value, family, weight, color);
const glyphCanvas = drawGlyphs(characters, family);
if (!textCanvas || !glyphCanvas) return;
upload(textTexture, textCanvas, false);
upload(glyphTexture, glyphCanvas, true);
state.textAspect = textCanvas.width / textCanvas.height;
state.glyphCount = Array.from(characters).length;
state.ready = true;
if (playIntro && settingsRef.current.intro && !state.introPlayed && !reduce) {
state.introPlayed = true;
state.intro = 1;
}
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(container.clientWidth * dpr));
const height = Math.max(1, Math.round(container.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
};
const frame = (now: number) => {
raf = 0;
if (!alive) return;
const s = settingsRef.current;
const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
last = now;
state.time += reduce ? 0 : dt;
const dpr = canvas.width / Math.max(1, container.clientWidth);
let targetX = 0;
let targetY = 0;
if (s.interactive && state.inside) {
targetX = state.targetY * 0.5 * s.tilt;
targetY = state.targetX * 0.5 * s.tilt;
} else if (s.idle && !reduce) {
targetX = Math.sin(state.time * 0.55) * 0.12 * s.tilt;
targetY = Math.sin(state.time * 0.4 + 1.3) * 0.2 * s.tilt;
}
const follow = 1 - Math.exp(-dt / 0.3);
state.tiltX += (targetX - state.tiltX) * follow;
state.tiltY += (targetY - state.tiltY) * follow;
const angle = Math.atan2(state.targetY, state.targetX);
const hueTarget = s.interactive && state.inside ? angle * s.hueShift : state.hue;
let delta = hueTarget - state.hue;
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
state.hue += delta * (1 - Math.exp(-dt / 0.25));
state.hover += ((s.interactive && state.inside ? 1 : 0) - state.hover) * (1 - Math.exp(-dt / 0.2));
if (state.intro > 0) state.intro = Math.max(0, state.intro - dt / 1.4);
rippleData.fill(0);
state.ripples = state.ripples.filter(ripple => (ripple.age += dt) < 1.3);
state.ripples.slice(-RIPPLES).forEach((ripple, i) => {
rippleData.set([ripple.x * dpr, ripple.y * dpr, ripple.age, 1], i * 4);
});
const width = canvas.width;
const height = canvas.height;
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textTexture);
gl.uniform1i(uniforms.uText, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, glyphTexture);
gl.uniform1i(uniforms.uGlyphs, 1);
gl.uniform2f(uniforms.uSize, width, height);
gl.uniform2f(uniforms.uCell, s.cellSize * 0.62 * dpr, s.cellSize * dpr);
gl.uniform1f(uniforms.uGlyphCount, state.glyphCount);
gl.uniform1f(uniforms.uTextAspect, state.textAspect);
gl.uniform1f(uniforms.uPlaneHeight, 0.5 * s.textScale);
gl.uniform2f(uniforms.uTilt, state.tiltX, state.tiltY);
gl.uniform1f(uniforms.uTime, state.time);
gl.uniform1f(uniforms.uWaves, s.waves);
gl.uniform1f(uniforms.uWaveSpeed, s.waveSpeed);
gl.uniform1f(uniforms.uChroma, s.chroma);
gl.uniform1f(uniforms.uBlocks, s.blocks);
const key = s.colors.join();
if (key !== colorKey) {
colorKey = key;
colorData.set(s.colors.flatMap(toRgb));
}
gl.uniform3fv(uniforms.uColors, colorData);
gl.uniform1f(uniforms.uHue, state.hue);
gl.uniform2f(uniforms.uPointer, state.pointerX * dpr, state.pointerY * dpr);
gl.uniform1f(uniforms.uHover, state.hover);
gl.uniform1f(uniforms.uScramble, s.scramble);
gl.uniform4fv(uniforms.uRipples, rippleData);
gl.uniform1f(uniforms.uIntro, state.intro);
gl.uniform1f(uniforms.uLight, s.light ? 1 : 0);
gl.uniform1f(uniforms.uReady, state.ready ? 1 : 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (state.visible) raf = requestAnimationFrame(frame);
};
const wake = () => {
if (raf || !alive || !state.visible) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
buildRef.current = (value: string, family: string, weight: number | string, color: string, characters: string) => {
build(value, family, weight, color, characters, true);
wake();
};
const locate = (event: PointerEvent) => {
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
state.pointerX = x;
state.pointerY = y;
state.targetX = clamp((x / Math.max(1, rect.width)) * 2 - 1, -1, 1);
state.targetY = clamp((y / Math.max(1, rect.height)) * 2 - 1, -1, 1);
};
const onMove = (event: PointerEvent) => {
locate(event);
state.inside = true;
};
const onLeave = () => {
state.inside = false;
};
const onDown = (event: PointerEvent) => {
const s = settingsRef.current;
if (!s.interactive || !s.clickRipple) return;
locate(event);
state.ripples.push({ x: state.pointerX, y: state.pointerY, age: 0 });
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
const visibility = new IntersectionObserver(entries => {
state.visible = entries.some(entry => entry.isIntersecting);
if (state.visible) wake();
});
visibility.observe(container);
container.addEventListener('pointermove', onMove);
container.addEventListener('pointerdown', onDown);
container.addEventListener('pointerleave', onLeave);
resize();
wake();
return () => {
alive = false;
cancelAnimationFrame(raf);
buildRef.current = null;
resizeObserver.disconnect();
visibility.disconnect();
container.removeEventListener('pointermove', onMove);
container.removeEventListener('pointerdown', onDown);
container.removeEventListener('pointerleave', onLeave);
gl.deleteTexture(textTexture);
gl.deleteTexture(glyphTexture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
};
}, []);
useEffect(() => {
let cancelled = false;
const run = () => {
if (!cancelled) buildRef.current?.(text, fontFamily, fontWeight, textColor, glyphs);
};
run();
const fonts = document.fonts;
if (fonts?.load) {
Promise.all([
fonts.load(`${fontWeight} ${TEXT_PX}px ${fontFamily}`, text || 'A'),
fonts.load(`500 ${GLYPH_HEIGHT}px ${fontFamily}`, glyphs)
])
.catch(() => null)
.then(run);
}
fonts?.addEventListener?.('loadingdone', run);
return () => {
cancelled = true;
fonts?.removeEventListener?.('loadingdone', run);
};
}, [text, fontFamily, fontWeight, textColor, glyphs]);
return (
<div
ref={containerRef}
className={className}
style={{ position: 'absolute', inset: 0, touchAction: 'pan-y', ...style }}
aria-label={text}
role="img"
>
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
{fallback && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily,
fontWeight,
fontSize: `${8 * textScale}vw`,
color: textColor
}}
>
{text}
</div>
)}
</div>
);
}