effects#ascii#retro
ASCII Renderer
Real-time conversion of animated wave patterns to ASCII art characters with mouse interaction
Implementation Guide
# ASCII Renderer Effect
> Real-time conversion of animated wave patterns to ASCII art characters. Retro-computing meets modern web with flowing plasma effects rendered as text.
## Quick Start
```tsx
import AsciiRenderer from '@/components/examples/effects/ascii-renderer';
export default function Hero() {
return (
<div className="relative h-screen">
<AsciiRenderer colorMode="color" />
<div className="relative z-10">{/* Your content goes here */}</div>
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| -------------- | ----------------- | ------------- | ------------------------------------------------- |
| characters | string | ' .:-=+\*#%@' | Character density string (light to dense) |
| fontSize | number | 12 | Font size in pixels for ASCII characters |
| colorMode | 'mono' \| 'color' | 'color' | Mono uses primary color, color uses shifting hues |
| animationSpeed | number | 1 | Animation speed multiplier (0.1 - 3.0) |
| primaryColor | string | '#007AFF' | Primary color for mono mode |
| waveIntensity | number | 1 | Intensity of the wave pattern (0.5 - 2.0) |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
interface AsciiRendererProps {
characters?: string;
fontSize?: number;
colorMode?: 'mono' | 'color';
animationSpeed?: number;
primaryColor?: string;
waveIntensity?: number;
}
export default function AsciiRenderer({
characters = ' .:-=+*#%@',
fontSize = 12,
colorMode = 'color',
animationSpeed = 1,
primaryColor = '#007AFF',
waveIntensity = 1,
}: AsciiRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationFrameRef = useRef<number>(0);
const timeRef = useRef<number>(0);
const mouseRef = useRef<{ x: number | null; y: number | null }>({
x: null,
y: null,
});
const prefersReducedMotionRef = useRef<boolean>(false);
const hexToRgb = useCallback((hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 122, b: 255 };
}, []);
const hslToRgb = useCallback(
(h: number, s: number, l: number): { r: number; g: number; b: number } => {
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
};
},
[]
);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const context = canvas.getContext('2d', { alpha: false });
if (!context) return;
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mediaQuery.matches;
const handleMotionPreferenceChange = (event: MediaQueryListEvent) => {
prefersReducedMotionRef.current = event.matches;
};
mediaQuery.addEventListener('change', handleMotionPreferenceChange);
// Calculate grid dimensions
const charWidth = fontSize * 0.6;
const charHeight = fontSize;
let cols = 0;
let rows = 0;
let width = 0;
let height = 0;
const resizeCanvas = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
width = rect.width;
height = rect.height;
canvas.width = width * dpr;
canvas.height = height * dpr;
context.scale(dpr, dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
cols = Math.ceil(width / charWidth);
rows = Math.ceil(height / charHeight);
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const handleMouseMove = (event: MouseEvent) => {
const rect = container.getBoundingClientRect();
mouseRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
const handleMouseLeave = () => {
mouseRef.current = { x: null, y: null };
};
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
const primaryRgb = hexToRgb(primaryColor);
const animate = () => {
const isReducedMotion = prefersReducedMotionRef.current;
const motionMultiplier = isReducedMotion ? 0.02 : 1;
timeRef.current += 0.016 * animationSpeed * motionMultiplier;
const time = timeRef.current;
// Clear with dark background
context.fillStyle = '#0a0a0a';
context.fillRect(0, 0, width, height);
// Set up monospace font
context.font = `${fontSize}px "JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace`;
context.textBaseline = 'top';
const mouse = mouseRef.current;
const mouseX = mouse.x ?? width / 2;
const mouseY = mouse.y ?? height / 2;
const hasMouseInput = mouse.x !== null && mouse.y !== null;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = col * charWidth;
const y = row * charHeight;
const centerX = x + charWidth / 2;
const centerY = y + charHeight / 2;
// Normalized coordinates
const normalizedX = centerX / width;
const normalizedY = centerY / height;
// Distance from mouse (normalized)
const distanceFromMouse = Math.sqrt(
Math.pow((centerX - mouseX) / width, 2) + Math.pow((centerY - mouseY) / height, 2)
);
// Generate plasma/wave pattern
const wave1 = Math.sin(normalizedX * 4 + time * 2) * waveIntensity;
const wave2 = Math.sin(normalizedY * 4 + time * 1.5) * waveIntensity;
const wave3 = Math.sin((normalizedX + normalizedY) * 3 + time * 1.8) * waveIntensity;
const wave4 =
Math.sin(
Math.sqrt(Math.pow(normalizedX - 0.5, 2) + Math.pow(normalizedY - 0.5, 2)) * 8 -
time * 2
) * waveIntensity;
// Combine waves for base value
let value = (wave1 + wave2 + wave3 + wave4) / 4;
// Add mouse proximity effect
if (hasMouseInput) {
const mouseEffect = Math.max(0, 1 - distanceFromMouse * 3);
const mouseWave = Math.sin(distanceFromMouse * 20 - time * 4) * mouseEffect;
value += mouseWave * 0.5;
}
// Normalize to 0-1
value = (value + waveIntensity) / (waveIntensity * 2);
value = Math.max(0, Math.min(1, value));
// Map value to character
const charIndex = Math.floor(value * (characters.length - 1));
const char = characters[charIndex];
// Calculate color
let r: number, g: number, b: number;
if (colorMode === 'color') {
// Color mode: create shifting hue based on position and time
const hue = (normalizedX * 0.3 + normalizedY * 0.2 + time * 0.1) % 1;
const saturation = 0.7 + value * 0.3;
const lightness = 0.3 + value * 0.4;
const rgb = hslToRgb(hue, saturation, lightness);
r = rgb.r;
g = rgb.g;
b = rgb.b;
// Add mouse proximity highlight
if (hasMouseInput) {
const mouseHighlight = Math.max(0, 1 - distanceFromMouse * 2.5);
r = Math.min(255, r + mouseHighlight * 100);
g = Math.min(255, g + mouseHighlight * 100);
b = Math.min(255, b + mouseHighlight * 100);
}
} else {
// Mono mode: use primary color with varying brightness
const brightness = 0.2 + value * 0.8;
r = Math.round(primaryRgb.r * brightness);
g = Math.round(primaryRgb.g * brightness);
b = Math.round(primaryRgb.b * brightness);
// Add mouse proximity effect in mono mode
if (hasMouseInput) {
const mouseHighlight = Math.max(0, 1 - distanceFromMouse * 2.5);
const boostFactor = 1 + mouseHighlight * 0.5;
r = Math.min(255, Math.round(r * boostFactor));
g = Math.min(255, Math.round(g * boostFactor));
b = Math.min(255, Math.round(b * boostFactor));
}
}
// Draw character
context.fillStyle = `rgb(${r}, ${g}, ${b})`;
context.fillText(char, x, y);
}
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationFrameRef.current);
window.removeEventListener('resize', resizeCanvas);
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
mediaQuery.removeEventListener('change', handleMotionPreferenceChange);
};
}, [
characters,
fontSize,
colorMode,
animationSpeed,
primaryColor,
waveIntensity,
hexToRgb,
hslToRgb,
]);
return (
<div
ref={containerRef}
className="h-full w-full overflow-hidden"
style={{ background: '#0a0a0a' }}
>
<canvas ref={canvasRef} className="h-full w-full" aria-hidden="true" />
</div>
);
}
```
## Customization
### Brand Colors (Mono Mode)
Match your brand with a single color theme:
```tsx
<AsciiRenderer colorMode="mono" primaryColor="#FF3B30" />
```
### Classic Terminal Green
For that authentic retro terminal feel:
```tsx
<AsciiRenderer colorMode="mono" primaryColor="#00FF00" fontSize={10} />
```
### Subtle Background
A slower, more subtle effect for backgrounds:
```tsx
<AsciiRenderer animationSpeed={0.3} waveIntensity={0.5} fontSize={14} />
```
### Dense Matrix Style
Higher character density for a more intense look:
```tsx
<AsciiRenderer characters=" .:;+=xX$#" fontSize={8} animationSpeed={1.5} />
```
### Minimal ASCII
Fewer characters for a cleaner appearance:
```tsx
<AsciiRenderer characters=" .-+#" fontSize={16} colorMode="mono" primaryColor="#5856D6" />
```
### High Energy
Fast-moving, intense visuals:
```tsx
<AsciiRenderer animationSpeed={2} waveIntensity={1.5} fontSize={10} />
```
## Performance Considerations
- **Font size** directly affects performance - larger fonts mean fewer characters to render
- The effect uses canvas rendering for optimal performance
- On mobile devices, consider using `fontSize={14}` or larger to reduce character count
- The animation loop runs at 60fps when possible
- GPU acceleration is utilized through canvas compositing
### Mobile Optimization Example
```tsx
import { useEffect, useState } from 'react';
function ResponsiveAsciiRenderer() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <AsciiRenderer fontSize={isMobile ? 16 : 12} animationSpeed={isMobile ? 0.7 : 1} />;
}
```
### Reducing CPU Usage
For less intensive animations on lower-end devices:
```tsx
<AsciiRenderer animationSpeed={0.5} fontSize={14} waveIntensity={0.7} />
```
## Accessibility
- Respects `prefers-reduced-motion` - animation speed is reduced to 2% of normal when users prefer reduced motion
- The canvas has `aria-hidden="true"` as it is purely decorative
- Ensure content above the effect has proper contrast ratios
- Content should have `position: relative; z-index: 10` to appear above the canvas
- The effect does not use any flashing or strobing patterns that could trigger photosensitive responses
## Browser Support
- Modern browsers with Canvas 2D support
- requestAnimationFrame support required
- MediaQueryList.addEventListener support (Chrome 39+, Firefox 55+, Safari 14+)
- Works in all modern browsers including Chrome, Firefox, Safari, and Edge
- Monospace font fallback chain ensures consistent rendering across platforms