Typewriter Animation

Animates text like a typewriter, with optional speed, cursor, and styling controls.

typewriter-animation-preview.jsx

import TypewriterAnimation from "@/components/text-effects/typewriter-animation/typewriter-animation";

const TypewriterAnimationPreview = () => {
  return (
    <h2>
      <TypewriterAnimation
        text="Typing the future, live."
        speed={75}
      />
    </h2>
  )
};

export default TypewriterAnimationPreview;

Installation

Run the command from your project root directory (the folder that contains package.json).

Terminal / Console

npx mosaicui-cli@latest text-effects/typewriter-animation

1. Copy the component file

Create a new file called typewriter-animation.jsx in your reusable components folder (for example /src/components/) and paste the following code into it.

typewriter-animation.jsx

import { memo, useEffect, useMemo, useState } from "react";
import styles from "./typewriter-animation.module.css";

const TypewriterAnimation = (props) => {
  const {
    text,
    words,
    hideCursor = false,
    blinkCursor = true,
    cursorVariant = "line",
    speed = 100,
    className,
    ...restProps
  } = props;

  const [currentWordIndex, setCurrentWordIndex] = useState(0);
  const [currentLetterIndex, setCurrentLetterIndex] = useState(-1);

  const wordsMap = useMemo(() => {
    let wordsArr = null;
    if(Array.isArray(words)) {
      wordsArr = words;
    } else {
      wordsArr = text.split(" ").filter(Boolean);
    }
    return wordsArr
    .map((word, wordIndex, arr) => {
      const temp = [{
        ...(typeof word === "string" ? ({
          letters: word.split(""),
          speed,
        }) : ({
          letters: word.text.split(""),
          speed,
          ...word,
        }))
      }];
      if (wordIndex !== (arr.length - 1)) {
        temp.push({
          letters: [" "],
          speed,
        });
      }
      return temp;
    }).flat();
  }, [text, words, speed]);

  const { isLastWord, isLastLetter } = useMemo(() => {
    const letters = wordsMap[currentWordIndex].letters;
    const isLastWord = currentWordIndex === (wordsMap.length-1);
    const isLastLetter = currentLetterIndex === (letters.length-1);
    return {
      isLastWord,
      isLastLetter,
    };
  }, [wordsMap, currentWordIndex, currentLetterIndex]);

  useEffect(() => {
    const timeoutDuration = wordsMap[currentWordIndex].speed ?? speed;
    const timeout = setTimeout(() => {
      if (isLastWord && isLastLetter) {
        return;
      } else if (isLastLetter) {
        setCurrentWordIndex(currentWordIndex+ 1);
        setCurrentLetterIndex(0);
      } else {
        setCurrentLetterIndex(currentLetterIndex + 1);
      }
    }, timeoutDuration);
    return () => {
      clearTimeout(timeout);
    };
  }, [
    wordsMap,
    currentWordIndex,
    currentLetterIndex,
    isLastWord,
    isLastLetter,
    speed,
  ]);

  useEffect(() => {
    setCurrentWordIndex(0);
    setCurrentLetterIndex(-1);
  }, [text, words, speed]);

  const isBlinkCursor = (
    blinkCursor && (
      isLastLetter === -1 ||
      (isLastWord && isLastLetter)
    )
  );

  const srOnlyText = useMemo(() => {
    if (Array.isArray(words)) {
      return words.map(word => word.text).join(" ");
    }
    return text;
  }, [text, words]);

  return (
    <span
      {...restProps}
      className={[
        className,
        styles["typewriter-animation"],
      ].join(" ")}
    >
      {wordsMap.map((word, wordIndex) => (
        <span 
          {...word.slotProps}
          key={`word-${wordIndex}`}
          aria-hidden={true}
          hidden={wordIndex > currentWordIndex}
        >
          {word.letters.map((letter, letterIndex) => (
            <span 
              key={`letter-${letterIndex}`}
              aria-hidden={true}
              className={styles["letter"]}
              hidden={(
                wordIndex === currentWordIndex && 
                letterIndex > currentLetterIndex
              )}
            >
              {letter}
            </span>
          ))}
        </span>
      ))}
      <span
        aria-hidden={true}
        className={[
          styles["cursor"],
          styles[cursorVariant],
          isBlinkCursor ? styles["blink"] : ""
        ].join(" ")}
        hidden={hideCursor}
      >
        {cursorVariant === "underscore" ? "_" : <>&nbsp;</>}
      </span>
      <span
        className={styles["sr-only"]}
      >
        {srOnlyText}
      </span>
    </span>
  );
};

export default memo(TypewriterAnimation);

2. Copy the CSS module file

In the same folder, create a file called typewriter-animation.module.css and paste the following CSS.

typewriter-animation.module.css

.typewriter-animation {
  --cursor-color: currentcolor;
  
  position: relative;

  .cursor {
    display: inline-block;
    background: var(--cursor-color);

    &.blink {
      animation: cursor-blink-keyframes 1500ms ease-in-out infinite;
    }

    &.line {
      width: 4px;
    }

    &.block {
      width: 12px;
    }

    &.underscore {
      background: transparent;
      animation-name: cursor-underscore-blink-keyframes;
    }
  }

  > .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
    animation: none;
  }
}

@keyframes cursor-blink-keyframes {
  0%,
  25%,
  75%,
  100% {
    background: transparent;
  }

  30%,
  70% {
    background: var(--cursor-color);
  }
}

@keyframes cursor-underscore-blink-keyframes {
  0%,
  25%,
  75%,
  100% {
    color: transparent
  }

  30%,
  70% {
    color: var(--cursor-color);
  }
}

Examples

Discover how supported props transform components to match your needs.

Cursor Types

Typing the future, live.


Typing the future, live.


Typing the future, live.

typewriter-animation

import TypewriterAnimation from "@/components/text-effects/typewriter-animation/typewriter-animation";

const TypewriterAnimationCursorsPreview = () => {
  const text = "Typing the future, live.";

  return (
    <div>
      <h2>
        <TypewriterAnimation
          text={text}
          cursorVariant="line"
        />
      </h2>
      <br />
      <h2>
        <TypewriterAnimation
          text={text}
          cursorVariant="block"
        />
      </h2>
      <br />
       <h2>
        <TypewriterAnimation
          text={text}
          cursorVariant="underscore"
        />
      </h2>
    </div>
  )
};

export default TypewriterAnimationCursorsPreview;

Customize word speed and style

Hello World

typewriter-animation

import { useMemo } from "react";
import TypewriterAnimation from "@/components/text-effects/typewriter-animation/typewriter-animation";

const TypewriterAnimationCustomizeWordsPreview = () => {
  /**
   * Make sure to memoize the words prop value to avoid re-renders 
   * React recommendation - https://react.dev/reference/react/useMemo
   */
  const words = useMemo(() => ([
    { 
      text: "Hello", 
      speed: 500, 
      slotProps: { 
        style: { color: "#22d6d7" } 
      },
    },
    { 
      text: "World", 
      speed: 50, 
      slotProps: { 
        style: { color: "#de287d" } 
      },
    },
  ]), []);

  return (
    <h2>
      <TypewriterAnimation
        words={words}
      />
    </h2>
  )
};

export default TypewriterAnimationCustomizeWordsPreview;

Props

Configure the component with the following props:

PropTypeRequiredDefaultDescription
textstringNoSimple text to type. Use for basic usage instead of words.
wordsArray<{ text: string; speed?: number; slotProps?: object }>NoAdvanced configuration for typing multiple entries. text defines the actual content to display, speed lets you control typing speed per word (overriding the global speed), and slotProps allows custom styling or additional props (e.g., style, className) for that specific word—giving you fine-grained visual and behavioral control.
hideCursorbooleanNofalseHides the typing cursor when set to true.
blinkCursorbooleanNotrueEnables cursor blinking animation.
cursorVariant"line" | "block" | "underscore"No"line"Controls the visual style of the cursor.
speednumberNo100Default typing speed in milliseconds (used if not overridden in words).
classNamestringNoOptional class name applied to the root container.
styleReact.CSSPropertiesNoInline styles applied to the root container.