Typewriter Text

Realistic typing animation with blinking cursor, variable speed, and delete-retype loop

animationtexttypewritertyping
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useEffect, useState, useCallback } from "react";
import { motion } from "framer-motion";

interface TypewriterTextProps {
  words?: string[];
  typingSpeed?: number;
  deletingSpeed?: number;
  pauseDuration?: number;
  cursorChar?: string;
  cursorClassName?: string;
  className?: string;
}

export default function TypewriterText({
  words = ["Hello World"],
  typingSpeed = 80,
  deletingSpeed = 50,
  pauseDuration = 1500,
  cursorChar = "|",
  cursorClassName = "",
  className = "",
}: TypewriterTextProps) {
  const [displayText, setDisplayText] = useState("");
  const [wordIndex, setWordIndex] = useState(0);
  const [isDeleting, setIsDeleting] = useState(false);
  const [isPaused, setIsPaused] = useState(false);

  const currentWord = words[wordIndex];

  const tick = useCallback(() => {
    if (isPaused) return;

    if (!isDeleting) {
      if (displayText.length < currentWord.length) {
        setDisplayText(currentWord.slice(0, displayText.length + 1));
      } else {
        setIsPaused(true);
        setTimeout(() => {
          setIsPaused(false);
          setIsDeleting(true);
        }, pauseDuration);
      }
    } else {
      if (displayText.length > 0) {
        setDisplayText(currentWord.slice(0, displayText.length - 1));
      } else {
        setIsDeleting(false);
        setWordIndex((prev) => (prev + 1) % words.length);
      }
    }
  }, [displayText, isDeleting, isPaused, currentWord, words.length, pauseDuration]);

  useEffect(() => {
    if (isPaused) return;

    const speed = isDeleting ? deletingSpeed : typingSpeed;
    // Add slight randomness for realism
    const jitter = Math.random() * 40 - 20;
    const timeout = setTimeout(tick, speed + jitter);
    return () => clearTimeout(timeout);
  }, [tick, isDeleting, isPaused, typingSpeed, deletingSpeed]);

  return (
    <span className={`inline-flex items-baseline ${className}`}>
      <span>{displayText}</span>
      <motion.span
        animate={{ opacity: [1, 0, 1] }}
        transition={{ duration: 0.8, repeat: Infinity, ease: "linear", times: [0, 0.5, 1] }}
        className={`ml-[1px] inline-block font-light ${cursorClassName || "text-current"}`}
      >
        {cursorChar}
      </motion.span>
    </span>
  );
}