Skip to content

developedbyed/react-gradient-glow

Repository files navigation

ASCII Animation Tutorial

This tutorial explains how to create a smooth, efficient ASCII animation in Next.js using Ghostty's animation approach.

Overview

We created a high-performance ASCII animation component that:

  • Uses requestAnimationFrame for smooth 60fps rendering
  • Loads ASCII frames from static files
  • Applies color overlays with CSS blend modes
  • Respects user motion preferences
  • Pauses when window loses focus

Step 1: Convert Video to ASCII Frames

1.1 Create ASCII Conversion Script

Create ascii.sh with these key configurations:

# Configuration
FONT_RATIO="0.44"
LUMINANCE_THRESHOLD=15  # Adjust to remove more/less background
ASCII_CHARS=" .'\`^,:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
OUTPUT_FPS=24
OUTPUT_COLUMNS=100

1.2 Generate ASCII Frames

./ascii.sh your-video.mp4

This creates a timestamped folder with 60 .txt files containing ASCII art.

1.3 Move Frames to Public Directory

mkdir -p public/frames
cp ascii_frames_*/frame_images/frame_*.txt public/frames/

Step 2: Create Animation Manager Class

2.1 RequestAnimationFrame-Based Timing

class AnimationManager {
  private _animation: number | null = null;
  private callback: () => void;
  private lastFrame = -1;
  private frameTime = 1000 / 30; // 30fps

  constructor(callback: () => void, fps = 30) {
    this.callback = callback;
    this.frameTime = 1000 / fps;
  }

  start() {
    if (this._animation != null) return;
    this._animation = requestAnimationFrame(this.update);
  }

  pause() {
    if (this._animation == null) return;
    this.lastFrame = -1;
    cancelAnimationFrame(this._animation);
    this._animation = null;
  }

  private update = (time: number) => {
    const { lastFrame } = this;
    let delta = time - lastFrame;
    if (this.lastFrame === -1) {
      this.lastFrame = time;
    } else {
      while (delta >= this.frameTime) {
        this.callback();
        delta -= this.frameTime;
        this.lastFrame += this.frameTime;
      }
    }
    this._animation = requestAnimationFrame(this.update);
  };
}

Step 3: Create ASCII Animation Component

3.1 Component Structure

interface ASCIIAnimationProps {
  frames?: string[];
  className?: string;
  fps?: number;
  colorOverlay?: boolean;
}

export default function ASCIIAnimation({
  frames: providedFrames,
  className = "",
  fps = 24,
  colorOverlay = false,
}: ASCIIAnimationProps) {
  const [frames, setFrames] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [currentFrame, setCurrentFrame] = useState(0);
  const framesRef = useRef<string[]>([]);

3.2 Frame Loading Logic

useEffect(() => {
  const loadFrames = async () => {
    if (providedFrames) {
      setFrames(providedFrames);
      framesRef.current = providedFrames;
      setIsLoading(false);
      return;
    }

    try {
      const frameFiles = Array.from(
        { length: 60 },
        (_, i) => `frame_${String(i + 1).padStart(4, "0")}.txt`,
      );

      const framePromises = frameFiles.map(async (filename) => {
        const response = await fetch(`/frames/${filename}`);
        if (!response.ok) {
          throw new Error(`Failed to fetch ${filename}: ${response.status}`);
        }
        return await response.text();
      });

      const loadedFrames = await Promise.all(framePromises);
      setFrames(loadedFrames);
      framesRef.current = loadedFrames;
      setCurrentFrame(0);
    } catch (error) {
      console.error("Failed to load ASCII frames:", error);
    } finally {
      setIsLoading(false);
    }
  };

  loadFrames();
}, [providedFrames]);

3.3 Animation Management

const [animationManager] = useState(
  () =>
    new AnimationManager(() => {
      setCurrentFrame((current) => {
        if (framesRef.current.length === 0) return current;
        return (current + 1) % framesRef.current.length;
      });
    }, fps),
);

useEffect(() => {
  if (frames.length === 0) return;

  const reducedMotion =
    window.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;

  if (reducedMotion) {
    return;
  }

  const handleFocus = () => animationManager.start();
  const handleBlur = () => animationManager.pause();

  window.addEventListener("focus", handleFocus);
  window.addEventListener("blur", handleBlur);

  if (document.visibilityState === "visible") {
    animationManager.start();
  }

  return () => {
    window.removeEventListener("focus", handleFocus);
    window.removeEventListener("blur", handleBlur);
    animationManager.pause();
  };
}, [animationManager, frames.length]);

3.4 Render with Color Overlay

return (
  <div className={`relative font-mono whitespace-pre overflow-hidden text-xs leading-none ${className}`}>
    <div>Frame: {currentFrame + 1}/{frames.length}</div>
    <div className="relative">
      {frames[currentFrame]}

      {/* Color overlay */}
      {colorOverlay && (
        <div
          className="absolute inset-0 pointer-events-none"
          style={{
            background:
              "radial-gradient(circle at center, rgba(143,145,3,1) 0%, rgba(64,64,64,1) 85%)",
            mixBlendMode: "color-dodge",
          }}
        />
      )}
    </div>
  </div>
);

Step 4: Integrate into Page

4.1 Import and Use Component

import ASCIIAnimation from "@/components/ascii-animation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export default function Home() {
  return (
    <main className="flex items-center flex-col m-auto max-w-6xl px-4">
      <Card className="mb-8">
        <CardHeader>
          <CardTitle>ASCII Animation</CardTitle>
        </CardHeader>
        <CardContent>
          <ASCIIAnimation fps={30} colorOverlay={true} />
        </CardContent>
      </Card>
    </main>
  );
}

Key Performance Optimizations

1. RequestAnimationFrame Timing

  • Uses precise frame timing calculation instead of setInterval
  • Syncs with browser's refresh rate for smooth animation

2. Efficient State Management

  • Uses useRef to avoid closure issues with frame data
  • Single state variable for current frame index

3. Smart Loading

  • Loads all frames upfront, no runtime I/O
  • Fallback mechanism for missing frames

4. Performance Features

  • Respects prefers-reduced-motion
  • Pauses animation when window loses focus
  • Uses pointer-events-none for overlays

5. CSS Blend Modes

  • color-dodge blend mode for vibrant color effects
  • Radial gradients positioned at content center
  • Minimal DOM manipulation

Customization Options

  • FPS: Adjust fps prop (24, 30, 60)
  • Colors: Modify radial gradient colors
  • Blend Modes: Try color, overlay, screen, multiply
  • Threshold: Adjust LUMINANCE_THRESHOLD in ascii.sh to remove more/less background
  • Size: Modify OUTPUT_COLUMNS for different resolutions

File Structure

project/
├── ascii.sh                          # Video conversion script
├── public/frames/                     # ASCII frame files
│   ├── frame_0001.txt
│   ├── frame_0002.txt
│   └── ...
├── components/ascii-animation.tsx     # Main component
└── app/page.tsx                      # Integration

This approach delivers the same buttery-smooth performance as Ghostty's terminal animations while being highly customizable for different visual effects.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published