React Typewriter Hook: HTML Support & Dual Animation Modes

Jun 01, 2025 (18 views)

useTypewriter Hook

I created this hook to handle a specific feature used in a production app. It worked nicely. Hopefully the comments help understand how it works.

Key Features:

Dual Animation Modes:

  • High-frequency mode: Uses setTimeout for precise 1ms timing control
  • Standard mode: Uses requestAnimationFrame for smooth, battery-efficient animations synced to display refresh rate

Smart Content Handling:

  • HTML tag support: Automatically skips HTML tags so they appear/disappear instantly while still animating the text content
  • Whitespace management: Intelligently handles consecutive spaces and preserves or collapses whitespace as needed

Bidirectional Animation:

  • Forward typing: Characters appear from left to right
  • Reverse untyping: Characters disappear from right to left
  • Independent controls: Separate speeds, delays, and completion callbacks for each direction

Performance & Reliability:

  • Memory leak prevention: Comprehensive cleanup of timers and animation frames
  • Component lifecycle management: Handles mounting/unmounting gracefully
  • Stale closure protection: Maintains fresh callback references
  • Multi-character processing: Handles very fast speeds by processing multiple characters per frame

Flexible Configuration:

  • Customizable typing/untyping speeds
  • Initial delays and reverse delays
  • Progress tracking with contextual completion percentages
  • Start/stop controls and completion callbacks
useTypewriter.tsx
export const useTypewriter = ({
  onComplete = () => {},
  onReverseComplete = () => {},
  speed = 5,
  reverseSpeed,
  text,
  skipHtmlTags = true,
  preserveWhitespace = false,
  delay = 0,
  reverseDelay = 0,
  start = true,
  reverse = false,
  useHighFrequency = true,
}: {
  onComplete?: () => void;
  onReverseComplete?: () => void;
  text: string;
  speed?: number;
  reverseSpeed?: number; // Optional separate speed for reverse mode
  skipHtmlTags?: boolean;
  preserveWhitespace?: boolean;
  delay?: number;
  reverseDelay?: number; // Optional separate delay for reverse mode
  start?: boolean;
  reverse?: boolean; // New prop to trigger reverse mode
  useHighFrequency?: boolean;
}) => {
  // STATE MANAGEMENT
  // Current text being displayed to the user (progressively built up or torn down)
  const [displayText, setDisplayText] = useState('');
  // Index of the current character position (moves forward in typing, backward in untyping)
  const [currentIndex, setCurrentIndex] = useState(0);
  // Flag to track if forward typing is complete
  const [isComplete, setIsComplete] = useState(false);
  // Flag to track if reverse untyping is complete
  const [isReverseComplete, setIsReverseComplete] = useState(false);
  // Flag to track if animation has started (after any delay)
  const [hasStarted, setHasStarted] = useState(false);
  // Flag to track if currently in reverse/untyping mode
  const [isReversing, setIsReversing] = useState(false);

  // REFS FOR CLEANUP AND TIMING
  // Reference to requestAnimationFrame ID for standard mode cleanup
  const rafRef = useRef<number | null>(null);
  // Reference to setTimeout ID for high-frequency mode cleanup
  const intervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Reference to delay timeout for cleanup (works for both forward and reverse delays)
  const delayTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Timestamp of last character update (used in requestAnimationFrame mode)
  const lastUpdateRef = useRef<number>(0);
  // Track if component is still mounted to prevent state updates after unmount
  const mountedRef = useRef(false);
  // Stable reference to forward completion callback to avoid stale closures
  const onCompleteRef = useRef(onComplete);
  // Stable reference to reverse completion callback to avoid stale closures
  const onReverseCompleteRef = useRef(onReverseComplete);

  // DYNAMIC SPEED AND DELAY CALCULATION
  // Use reverse-specific settings if in reverse mode, otherwise use normal settings
  const effectiveSpeed = isReversing ? (reverseSpeed ?? speed) : speed;
  const effectiveDelay = isReversing ? reverseDelay : delay;

  // COMPONENT LIFECYCLE TRACKING
  // Track mounting state to prevent memory leaks and state updates on unmounted components
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
    };
  }, []);

  // Keep both callback references updated to avoid stale closures
  useEffect(() => {
    onCompleteRef.current = onComplete;
    onReverseCompleteRef.current = onReverseComplete;
  }, [onComplete, onReverseComplete]);

  // REVERSE MODE TRIGGER LOGIC
  // When reverse prop becomes true and forward typing is complete, initiate reverse mode
  useEffect(() => {
    if (reverse && isComplete && !isReversing) {
      setIsReversing(true);
      setHasStarted(false); // Reset to allow delay logic to work for reverse
      setIsReverseComplete(false);
      // Set currentIndex to full text length so we can start untyping from the end
      setCurrentIndex(text.length);
    }
  }, [reverse, isComplete, isReversing, text.length]);

  // RESET STATE WHEN TEXT OR START PROP CHANGES
  useEffect(() => {
    // Only reset if we're not in reverse mode (reverse mode has its own state management)
    if (!reverse) {
      // Reset all state to initial values for fresh start
      setDisplayText('');
      setCurrentIndex(0);
      setIsComplete(false);
      setIsReverseComplete(false);
      setHasStarted(false);
      setIsReversing(false);
      lastUpdateRef.current = 0;
    }

    // Clear any existing delay timeout to prevent conflicts
    if (delayTimeoutRef.current) {
      clearTimeout(delayTimeoutRef.current);
      delayTimeoutRef.current = null;
    }
  }, [text, start, reverse]);

  // FORWARD CHARACTER NAVIGATION LOGIC
  // Determines the next valid character index when typing forward
  const getNextValidIndex = useCallback(
    (index: number): number => {
      // Don't go beyond the text length
      if (index >= text.length) return text.length;

      let nextIndex = index;

      // Skip HTML tags if enabled (jump from '<' to the character after '>')
      if (skipHtmlTags && text[index] === '<') {
        const closingTagIndex = text.indexOf('>', index);
        if (closingTagIndex !== -1) {
          // Jump past the entire HTML tag
          nextIndex = closingTagIndex + 1;
        } else {
          // If no closing tag found, just move one character
          nextIndex = index + 1;
        }
      } else {
        // Normal case: move to next character
        nextIndex = index + 1;
      }

      // Skip consecutive whitespace if preserveWhitespace is false
      // This prevents multiple spaces from being typed one by one
      if (!preserveWhitespace && nextIndex < text.length) {
        while (
          nextIndex < text.length &&
          /\s/.test(text[nextIndex]) && // Current character is whitespace
          /\s/.test(text[nextIndex - 1]) // Previous character is also whitespace
        ) {
          nextIndex++;
        }
      }

      return nextIndex;
    },
    [text, skipHtmlTags, preserveWhitespace]
  );

  // REVERSE CHARACTER NAVIGATION LOGIC
  // Determines the previous valid character index when untyping backward
  const getPrevValidIndex = useCallback(
    (index: number): number => {
      // Don't go below 0
      if (index <= 0) return 0;

      let prevIndex = index - 1;

      // Skip HTML tags if enabled (going backwards from '>' to before '<')
      if (skipHtmlTags && prevIndex >= 0) {
        // If we're at the end of an HTML tag, jump to before the opening tag
        if (text[index - 1] === '>') {
          const openingTagIndex = text.lastIndexOf('<', index - 1);
          if (openingTagIndex !== -1) {
            prevIndex = openingTagIndex;
          }
        }
      }

      // Skip consecutive whitespace if preserveWhitespace is false (going backwards)
      // This ensures we don't remove spaces one by one when untyping
      if (!preserveWhitespace && prevIndex > 0) {
        while (
          prevIndex > 0 &&
          /\s/.test(text[prevIndex]) && // Current character is whitespace
          /\s/.test(text[prevIndex + 1]) // Next character is also whitespace
        ) {
          prevIndex--;
        }
      }

      return Math.max(0, prevIndex);
    },
    [text, skipHtmlTags, preserveWhitespace]
  );

  // CORE TYPING/UNTYPING LOGIC
  // Handles both forward typing and reverse untyping based on current mode
  const typeNextCharacter = useCallback(() => {
    if (isReversing) {
      // REVERSE MODE: Remove characters from the end
      if (currentIndex <= 0) {
        // We've reached the beginning - reverse is complete
        setIsReverseComplete(true);
        setDisplayText(''); // Ensure display is completely empty
        // Trigger reverse completion callback after state update
        setTimeout(() => {
          onReverseCompleteRef.current();
        }, 0);
        return;
      }

      // Get the previous valid index (moving backwards)
      const prevIndex = getPrevValidIndex(currentIndex);
      // Update displayed text to show characters up to the new position
      setDisplayText(text.slice(0, prevIndex));
      // Move the cursor position backwards
      setCurrentIndex(prevIndex);
    } else {
      // FORWARD MODE: Add characters to the end
      if (currentIndex >= text.length) {
        // We've reached the end - forward typing is complete
        setIsComplete(true);
        // Trigger forward completion callback after state update
        setTimeout(() => {
          onCompleteRef.current();
        }, 0);
        return;
      }

      // Get the next valid index (moving forwards)
      const nextIndex = getNextValidIndex(currentIndex);
      // Update displayed text to show characters up to the new position
      setDisplayText(text.slice(0, nextIndex));
      // Move the cursor position forwards
      setCurrentIndex(nextIndex);
    }
  }, [currentIndex, text, isReversing, getNextValidIndex, getPrevValidIndex]);

  // HIGH-FREQUENCY ANIMATION MODE
  // Uses setTimeout for precise timing control in both forward and reverse modes
  const animateHighFrequency = useCallback(() => {
    // Safety checks: don't continue if unmounted or if the appropriate mode is complete
    if (!mountedRef.current || (!isReversing && isComplete) || (isReversing && isReverseComplete) || !hasStarted) return;

    // Process the next character (forward or reverse)
    typeNextCharacter();

    // Determine if we should continue animation based on current mode
    const shouldContinue = isReversing ? currentIndex > 0 : currentIndex < text.length;
    
    // Schedule the next animation frame if there are more characters to process
    if (shouldContinue) {
      intervalRef.current = setTimeout(
        animateHighFrequency,
        Math.max(1, effectiveSpeed) // Ensure minimum 1ms delay
      );
    }
  }, [
    isComplete,
    isReverseComplete,
    hasStarted,
    currentIndex,
    text.length,
    effectiveSpeed,
    typeNextCharacter,
    isReversing,
  ]);

  // STANDARD ANIMATION MODE
  // Uses requestAnimationFrame for smooth, display-synced animation in both modes
  const animate = useCallback(
    (timestamp: number) => {
      // Safety checks: don't continue if unmounted or if the appropriate mode is complete
      if (!mountedRef.current || (!isReversing && isComplete) || (isReversing && isReverseComplete) || !hasStarted) return;

      // Calculate time since last character was processed
      const timeSinceLastUpdate = timestamp - lastUpdateRef.current;

      // Only proceed if enough time has passed based on effective speed setting
      if (timeSinceLastUpdate >= effectiveSpeed) {
        // For very fast speeds, process multiple characters per frame to maintain speed
        const charactersToProcess = Math.max(
          1,
          Math.floor(timeSinceLastUpdate / effectiveSpeed)
        );

        let nextIndex = currentIndex;
        
        if (isReversing) {
          // REVERSE MODE: Process multiple characters backwards
          for (let i = 0; i < charactersToProcess && nextIndex > 0; i++) {
            nextIndex = getPrevValidIndex(nextIndex);
          }

          // Check if we've completed reverse animation
          if (nextIndex <= 0) {
            setDisplayText('');
            setCurrentIndex(0);
            setIsReverseComplete(true);
            // Trigger reverse completion callback
            setTimeout(() => {
              onReverseCompleteRef.current();
            }, 0);
            return;
          }
        } else {
          // FORWARD MODE: Process multiple characters forwards
          for (let i = 0; i < charactersToProcess && nextIndex < text.length; i++) {
            nextIndex = getNextValidIndex(nextIndex);
          }

          // Check if we've completed forward animation
          if (nextIndex >= text.length) {
            setDisplayText(text);
            setCurrentIndex(text.length);
            setIsComplete(true);
            // Trigger forward completion callback
            setTimeout(() => {
              onCompleteRef.current();
            }, 0);
            return;
          }
        }

        // Update display and tracking for both modes
        setDisplayText(text.slice(0, nextIndex));
        setCurrentIndex(nextIndex);
        lastUpdateRef.current = timestamp;
      }

      // Schedule next animation frame
      rafRef.current = requestAnimationFrame(animate);
    },
    [currentIndex, text, effectiveSpeed, isComplete, isReverseComplete, hasStarted, getNextValidIndex, getPrevValidIndex, isReversing]
  );

  // START TRIGGER AND DELAY HANDLING
  // Manages when typing or untyping animation should begin, with appropriate delays
  useEffect(() => {
    // Determine which trigger should start the animation based on current mode
    const shouldStart = isReversing ? reverse : start;
    // Check if the current mode is already complete
    const isAlreadyComplete = isReversing ? isReverseComplete : isComplete;
    
    // Don't start if conditions aren't met
    if (!shouldStart || !text || isAlreadyComplete) return;

    // Clear any existing delay timeout to prevent conflicts
    if (delayTimeoutRef.current) {
      clearTimeout(delayTimeoutRef.current);
    }

    // Apply the appropriate delay (reverseDelay for reverse mode, delay for forward mode)
    if (effectiveDelay > 0) {
      delayTimeoutRef.current = setTimeout(() => {
        setHasStarted(true);
      }, effectiveDelay);
    } else {
      // No delay - start immediately
      setHasStarted(true);
    }

    // Cleanup function
    return () => {
      if (delayTimeoutRef.current) {
        clearTimeout(delayTimeoutRef.current);
        delayTimeoutRef.current = null;
      }
    };
  }, [start, reverse, text, effectiveDelay, isComplete, isReverseComplete, isReversing]);

  // ANIMATION INITIALIZATION
  // Starts the appropriate animation mode when hasStarted becomes true
  useEffect(() => {
    // Check if the current mode is already complete
    const isAlreadyComplete = isReversing ? isReverseComplete : isComplete;
    // Don't start animation if conditions aren't met
    if (!hasStarted || isAlreadyComplete || !text) return;

    const startAnimation = () => {
      if (useHighFrequency) {
        // High-frequency mode: use setTimeout for precise timing
        intervalRef.current = setTimeout(
          animateHighFrequency,
          Math.max(1, effectiveSpeed)
        );
      } else {
        // Standard mode: use requestAnimationFrame for smooth animation
        lastUpdateRef.current = performance.now();
        rafRef.current = requestAnimationFrame(animate);
      }
    };

    // Small delay ensures component is fully mounted and browser is ready
    // This prevents timing issues with requestAnimationFrame
    const timeoutId = setTimeout(startAnimation, 0);

    // Cleanup function
    return () => {
      clearTimeout(timeoutId);
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
      if (intervalRef.current) {
        clearTimeout(intervalRef.current);
        intervalRef.current = null;
      }
    };
  }, [
    hasStarted,
    isComplete,
    isReverseComplete,
    text,
    useHighFrequency,
    animate,
    animateHighFrequency,
    effectiveSpeed,
    isReversing,
  ]);

  // CLEANUP ON UNMOUNT
  // Ensures all timers and animation frames are cleaned up to prevent memory leaks
  useEffect(() => {
    return () => {
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }
      if (intervalRef.current) {
        clearTimeout(intervalRef.current);
      }
      if (delayTimeoutRef.current) {
        clearTimeout(delayTimeoutRef.current);
      }
    };
  }, []);

  // RETURN VALUES
  // Expose the current state and progress to the consuming component
  return {
    displayText, // Current text to display (progressively typed or untyped)
    isComplete, // Whether forward typing is finished
    isReverseComplete, // Whether reverse untyping is finished
    hasStarted, // Whether animation has begun (after any delay)
    isReversing, // Whether currently in reverse/untyping mode
    // Progress calculation adapts based on current mode:
    // - Forward mode: percentage of characters typed (0-100%)
    // - Reverse mode: percentage of characters removed (0-100%)
    progress: isReversing 
      ? (text.length > 0 ? ((text.length - currentIndex) / text.length) * 100 : 0) // Reverse progress
      : (text.length > 0 ? (currentIndex / text.length) * 100 : 0), // Forward progress
  };
};

Usage

TypewriterDemo.tsx
const TypewriterDemo: FC = () => {
  const [showReverse, setShowReverse] = useState(false);

  const { displayText, isReversing } = useTypewriter({
    text: 'Hello <strong>World</strong>! This text will type and untype.',
    speed: 30,
    reverseSpeed: 20, // Faster untyping
    reverseDelay: 1000, // Wait 1 second before untyping
    reverse: showReverse,
    onComplete: () => {
      // Auto-trigger reverse after typing completes
      setTimeout(() => setShowReverse(true), 1000);
    },
    onReverseComplete: () => {
      console.log('Untyping complete!');
      setShowReverse(false); // Reset for potential replay
    },
  });

  return (
    <div>
      <div className="h-6" dangerouslySetInnerHTML={{ __html: displayText }} />
      <p>Status: {isReversing ? 'Untyping...' : 'Typing...'}</p>
    </div>
  );
};