2024-10-31 Web Development, Video Production, Programming

Building a Typing Code Animation Component

By O. Wolfson

This article will guide you through building a typing code animation component in React/Next.js. This component types out code with syntax highlighting, providing an engaging way to display code snippets for tutorials, presentations, or educational content. Incidentally, this setup is perfect for video recording, as the component is sized at 720p to maintain consistent formatting and high resolution for tutorial videos or presentations.

Here below is a simple example of how to output text one character at a time. This can be run as a node script to see the effect.

javascript
const content = `Hello, App Router! 
Hello World`;

let sentence = "";

const typeTest = async () => {
  for (let index = 0; index < content.length; index++) {
    sentence = content.slice(0, index + 1);
    await new Promise((resolve) => setTimeout(resolve, 50));
    console.log(sentence);
  }
};

typeTest();

Overview of the Component

The component takes an array of code blocks and animates the typing of each block one character at a time. It includes an elapsed time display to track how long the animation has been running and provides an estimated total time for completion.

Core Features

  • Typing Animation: Each code block is revealed character by character with a specified delay.
  • Elapsed Time HUD: Displays the elapsed time and the estimated total time for the animation.
  • Syntax Highlighting: Uses the react-syntax-highlighter library with a customizable theme for highlighting code.

Code Walkthrough

Let's dive into the code and explain how each function and component works:

typescript
"use client";
import { useState, useRef } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { darcula } from "react-syntax-highlighter/dist/esm/styles/prism";

// Type definition for a code block
interface CodeBlock {
  time: number;
  content: string;
  delay: number;
}

// Code block configuration
const codeBlocks: CodeBlock[] = [
  {
    time: 1000,
    content: `
import { createApiClient } from "./apiClient";
import { getSession } from "./sessionHandler";

export async function initializeClient() {
  const session = await getSession();

  return createApiClient(
    process.env.API_BASE_URL ?? "https://default.api.url",
    process.env.API_KEY ?? "default_api_key",
    {
      sessionData: {
        getAll: () => session.getAllSessionData(),
        setAll: (dataToSet) => {
          for (const { key, value, options } of dataToSet) {
            session.setSessionData(key, value, options);
          }
        },
        clear: () => session.clearAllData(),
      },
    }
  );
}
    `,
    delay: 50,
  },
];

// Function to type each character in the content
async function typeCode(
  content: string,
  delay: number,
  onUpdate: (newText: string) => void
): Promise<void> {
  for (let index = 0; index < content.length; index++) {
    onUpdate(content.slice(0, index + 1));
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

// Helper function to calculate the total estimated time for animation
function calculateTotalAnimationTime(codeBlocks: CodeBlock[]): number {
  const totalCharacters = codeBlocks.reduce(
    (sum, block) => sum + block.content.length,
    0
  );
  const totalDelay = codeBlocks.reduce((sum, block) => sum + block.time, 0);
  const typingTime = totalCharacters * codeBlocks[0].delay;
  return (totalDelay + typingTime) / 1000; // Convert to seconds
}

export default function CodeAnimation() {
  const [displayedCode, setDisplayedCode] = useState<{ text: string }[]>([]);
  const [started, setStarted] = useState<boolean>(false);
  const [elapsedTime, setElapsedTime] = useState<number>(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  const totalEstimatedTime = calculateTotalAnimationTime(codeBlocks);

  // Timer management functions
  const startTimer = () => {
    timerRef.current = setInterval(
      () => setElapsedTime((prev) => prev + 1),
      1000
    );
  };
  const stopTimer = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
    }
  };

  // Function to start the animation
  const startAnimation = () => {
    setStarted(true);
    setElapsedTime(0);
    startTimer();

    codeBlocks.forEach((block, idx) => {
      setTimeout(async () => {
        setDisplayedCode((prev) => [...prev, { text: "" }]);

        await typeCode(block.content, block.delay, (newText) =>
          setDisplayedCode((prev) => {
            const updatedBlocks = [...prev];
            updatedBlocks[idx].text = newText;
            return updatedBlocks;
          })
        );

        if (idx === codeBlocks.length - 1) stopTimer();
      }, block.time);
    });
  };

  return (
    <div>
      <div className="text-white p-2 rounded text-sm text-center">
        Elapsed Time: {elapsedTime} seconds / Estimated Total Time:{" "}
        {totalEstimatedTime.toFixed(2)} seconds
      </div>
      <div className="flex border items-center border-gray-800 h-[720px] w-[1280px] mx-auto">
        <div className="code-animation-container w-full font-mono p-5 rounded-lg max-w-[1000px] mx-auto text-center text-xl">
          {started ? (
            displayedCode.map((block, idx) => (
              <SyntaxHighlighter
                key={idx}
                language="javascript"
                style={darcula}
                className="custom-syntax-highlighter"
              >
                {block.text}
              </SyntaxHighlighter>
            ))
          ) : (
            <button
              type="button"
              onClick={startAnimation}
              className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
            >
              Start Animation
            </button>
          )}
          <style jsx>{`
            :global(.custom-syntax-highlighter pre),
            :global(.custom-syntax-highlighter code),
            :global(.custom-syntax-highlighter) {
              background: none !important;
            }
          `}</style>
        </div>
      </div>
    </div>
  );
}

Explanation of the Code

1. typeCode Function

This function handles the animation of typing out the code block one character at a time. It iterates through the content string and calls the onUpdate callback with the updated string at each step, adding a delay between characters using setTimeout.

2. calculateTotalAnimationTime Function

Calculates the estimated total time for the entire animation by summing up:

  • The number of characters across all code blocks multiplied by the delay per character.
  • The initial delay (time) for each code block before it starts typing.

This is displayed in the HUD to give users an idea of how long the animation will take.

3. Timer Management Functions

  • startTimer: Starts the timer and updates elapsedTime every second.
  • stopTimer: Stops the timer when the animation is complete.

4. startAnimation Function

The main function that controls the animation flow:

  • Resets and starts the timer.
  • Iterates through each code block and starts typing after a specified initial time.
  • Updates displayedCode to reflect the currently typed characters.

5. Component Structure

  • The component displays a button to start the animation.
  • Once started, it shows the typing animation of the code blocks using react-syntax-highlighter with the darcula theme for syntax highlighting.
  • A HUD at the top displays the elapsed time and the total estimated time for the animation.

Styling

Most of the styling is handled using Tailwind CSS classes for consistency and maintainability. Custom styles are added using the :global rule to remove the background of the syntax highlighter.

Sound Effects

To add a bit of realism, I added a sound effect that plays when the animation starts. This is done using the useEffect hook to initialize the audio object and the play method to start the sound.

typescript
"use client";
import { useState, useRef, useEffect } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { darcula } from "react-syntax-highlighter/dist/esm/styles/prism";

// Type definition for a code block
interface CodeBlock {
  time: number;
  content: string;
  delay: number;
}

// Code block configuration
const codeBlocks: CodeBlock[] = [
  {
    time: 1000,
    content: `
import { createApiClient } from "./apiClient";
import { getSession } from "./sessionHandler";

export async function initializeClient() {
  const session = await getSession();

  return createApiClient(
    process.env.API_BASE_URL ?? "https://default.api.url",
    process.env.API_KEY ?? "default_api_key",
    {
      sessionData: {
        getAll: () => session.getAllSessionData(),
        setAll: (dataToSet) => {
          for (const { key, value, options } of dataToSet) {
            session.setSessionData(key, value, options);
          }
        },
        clear: () => session.clearAllData(),
      },
    }
  );
}
    `,
    delay: 50,
  },
];

// Function to type each character in the content
async function typeCode(
  content: string,
  delay: number,
  onUpdate: (newText: string) => void
): Promise<void> {
  for (let index = 0; index < content.length; index++) {
    onUpdate(content.slice(0, index + 1));
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

// Helper function to calculate the total estimated time for animation
function calculateTotalAnimationTime(codeBlocks: CodeBlock[]): number {
  const totalCharacters = codeBlocks.reduce(
    (sum, block) => sum + block.content.length,
    0
  );
  const totalDelay = codeBlocks.reduce((sum, block) => sum + block.time, 0);
  const typingTime = totalCharacters * codeBlocks[0].delay;
  return (totalDelay + typingTime) / 1000; // Convert to seconds
}

export default function CodeAnimation() {
  const [displayedCode, setDisplayedCode] = useState<{ text: string }[]>([]);
  const [started, setStarted] = useState<boolean>(false);
  const [elapsedTime, setElapsedTime] = useState<number>(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);
  const totalEstimatedTime = calculateTotalAnimationTime(codeBlocks);
  const typingSoundRef = useRef<HTMLAudioElement | null>(null);

  useEffect(() => {
    // Initialize the audio object
    typingSoundRef.current = new Audio("/typing.mp3");
  }, []);

  // Timer management functions
  const startTimer = () => {
    timerRef.current = setInterval(
      () => setElapsedTime((prev) => prev + 1),
      1000
    );
  };
  const stopTimer = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
    }
  };

  // Function to start the animation
  const startAnimation = () => {
    setStarted(true);
    setElapsedTime(0);
    startTimer();

    // Play typing sound
    if (typingSoundRef.current) {
      typingSoundRef.current.loop = true; // Loop the sound while typing
      typingSoundRef.current.play().catch((err) => {
        console.error("Error playing sound:", err);
      });
    }

    codeBlocks.forEach((block, idx) => {
      setTimeout(async () => {
        setDisplayedCode((prev) => [...prev, { text: "" }]);

        await typeCode(block.content, block.delay, (newText) =>
          setDisplayedCode((prev) => {
            const updatedBlocks = [...prev];
            updatedBlocks[idx].text = newText;
            return updatedBlocks;
          })
        );

        if (idx === codeBlocks.length - 1) {
          // Stop the typing sound when the animation completes
          if (typingSoundRef.current) {
            typingSoundRef.current.pause();
            typingSoundRef.current.currentTime = 0; // Reset for next play
          }
          stopTimer();
        }
      }, block.time);
    });
  };

  return (
    <div>
      <div className="text-white p-2 rounded text-sm text-center">
        Elapsed Time: {elapsedTime} seconds / Estimated Total Time:{" "}
        {totalEstimatedTime.toFixed(2)} seconds
      </div>
      <div className="flex border items-center border-gray-800 h-[720px] w-[1280px] mx-auto">
        <div className="code-animation-container w-full font-mono p-5 rounded-lg max-w-[1000px] mx-auto text-center text-xl">
          {started ? (
            displayedCode.map((block, idx) => (
              <SyntaxHighlighter
                // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
                key={idx}
                language="javascript"
                style={darcula}
                className="custom-syntax-highlighter"
              >
                {block.text}
              </SyntaxHighlighter>
            ))
          ) : (
            <button
              type="button"
              onClick={startAnimation}
              className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
            >
              Start Animation
            </button>
          )}
          <style jsx>{`
            :global(.custom-syntax-highlighter pre),
            :global(.custom-syntax-highlighter code),
            :global(.custom-syntax-highlighter) {
              background: none !important;
            }
          `}</style>
        </div>
      </div>
    </div>
  );
}