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