Typewriter

Typewriter effect for text. Supports html and markdown.

Installation

1

Install the package if you do not have it.

npm install react-markdown
2

Copy and paste the following code into your project.

'use client';
import { cn } from '@/lib/utils';
import React, { useEffect, useRef, useState } from 'react';
import Markdown, { Components } from 'react-markdown';

type FlashCursorProps = React.HTMLAttributes<HTMLSpanElement> & { hideCursor?: boolean };

export const FlashCursor = ({ hideCursor, className, ...props }: FlashCursorProps) => {
  return (
    <span
      className={cn(
        'animate-blink border-r-4 border-r-primary/60 pl-1 text-transparent',
        className,
        hideCursor && 'hidden',
      )}
      {...props}
    />
  );
};

type TypewriterProps = {
  text?: string;
  typeSpeed?: number;
  className?: string;
  onComplete?: () => void;
  renderMarkdown?: boolean;
  markdownComponents?: Components;
};

export const Typewriter = ({
  text = '',
  typeSpeed = 33,
  onComplete,
  className,
  renderMarkdown,
  markdownComponents,
}: TypewriterProps) => {
  const [displayedText, setDisplayedText] = useState('');
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
  const onCompleteRef = useRef(onComplete); // Ref to store the latest onComplete

  // Keep onComplete callback reference up-to-date without causing effect re-runs
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);
  useEffect(() => {
    /***
     * Each time htmlString changes,
     * only add new characters from the end of the currently displayed text.
     */
    const startTyping = () => {
      let currentIndex = displayedText.length;
      intervalRef.current = setInterval(() => {
        if (currentIndex < text.length) {
          // Only add new characters, do not reset old text
          setDisplayedText(text.slice(0, currentIndex + 1));
          currentIndex++;
        } else {
          if (intervalRef.current) {
            clearInterval(intervalRef.current);
          }
          onCompleteRef.current?.();
        }
      }, typeSpeed);
    };

    // If there is new text, start typing animation
    if (text.length > displayedText.length) {
      // Start typing immediately, no need to wait for delay
      startTyping();
    }
    // If targetText decreases or resets, consider handling it自行處理
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text, typeSpeed]);

  if (renderMarkdown) {
    return (
      <div className={className}>
        <Markdown components={markdownComponents}>{displayedText}</Markdown>
      </div>
    );
  }

  return (
    <span
      className={cn('whitespace-pre-wrap leading-7', className)}
      dangerouslySetInnerHTML={{ __html: displayedText }}
    />
  );
};
3

Copy following animation to tailwind.config.js


      keyframes: {
        'blink-caret': {
          '0%, 100%': { opacity: '0' },
          '50%': { opacity: '1' },
        },
      },
      'blink-caret': {
        '0%, 100%': { opacity: '0' },
        '50%': { opacity: '1' },
      },
4

Update the import paths to match your project setup.

Markdown

Following markdown style is using tailwindcss-typography

Playground for ChatGPT

Here is the ChatGPT integration playground. Please enter your token to test. Don't worry, the API endpoint does not store your token. You can check the API source code.
If you still have concerns, you can clone the project and test it locally.
Default is using gpt-4o-mini model.

Properties

PropertyTypeDefault
text
string
string
typeSpeed
number
33
renderMarkdown
boolean
markdownComponents
Components
onComplete
() => void
className
string
Buy Me A CoffeeDeployed on Zeabur