try your own text

|

Typewriter

A typewriter that types, pauses, deletes and loops sentences letter by letter, with per-character reveal effects: blur, rise, pop and glitch.

A free, copy-paste text animations component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

effectblur · fade · rise · pop · glitch · none — default "blur".
typingSpeed20–120ms — default 55ms.
deletingSpeed10–100ms — default 28ms.
pauseDuration500–4000ms — default 1800ms.
cursor| · _ · ▍ — default "|".
cursorBlinkDuration0.2–1.2s — default 0.5s.
showCursoron / off — default on.
hideCursorWhileTypingon / off — default off.
variableSpeedon / off — default off.
reverseModeon / off — default off.
loopon / off — default on.

Interaction Type

  • Watch it type letter by letter
  • It deletes and cycles to the next sentence
  • Supports start-on-visible via IntersectionObserver

Dependency

No dependencies — plain React. Just drop the file in.

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/typewriter.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/typewriter

How to use

Use Typewriter when you want text that behaves like it is being typed by someone — it types letter by letter, holds, deletes, and moves on to the next sentence. Pass a single string for a one-shot line or an array of strings to cycle through.

Give it an effect to make each character reveal itself as it lands — blur-in (the default), fade, rise, pop or glitch — or set effect="none" for the classic instant type. Both the cursor and the effects are plain CSS animations, so the component still ships with zero dependencies. Everything else is tweakable too: speeds, pauses, colors, cursor character and blink rate.

Basic usage

import { Typewriter } from "@/components/harsh-ui/typewriter";

const Demo = () => (
  <Typewriter text="Build interfaces people feel." />
);

Reveal effect

Each character animates in as it is typed. Pick from "blur" (default), "fade", "rise", "pop", "glitch", or "none" for the classic instant type. Pure CSS — still zero dependencies.

<Typewriter
  text="Type with a little drama."
  effect="glitch"
/>

Multiple sentences

With an array, Typewriter finishes a sentence, waits pauseDuration, deletes it and types the next one. loop keeps it cycling forever.

<Typewriter
  text={[
    "Build interfaces people feel.",
    "One component. One letter at a time.",
    "Type. Delete. Repeat.",
  ]}
  typingSpeed={55}
  deletingSpeed={28}
  pauseDuration={1800}
  loop
/>

Per-sentence colors + human typing

textColors cycles by sentence index. variableSpeed randomizes each keystroke between min and max so it feels typed by a person.

<Typewriter
  text={["Blue thoughts.", "Red thoughts."]}
  textColors={["#3b82f6", "#ef4444"]}
  variableSpeed={{ min: 30, max: 120 }}
/>

Start on visible

Defers typing until the component scrolls into view (IntersectionObserver).

<Typewriter
  text="You had to scroll to see me."
  startOnVisible
  initialDelay={250}
/>

Custom cursor

<Typewriter
  text="Custom cursor, custom blink."
  cursorCharacter="_"
  cursorBlinkDuration={0.8}
  cursorClassName="text-accent-blue"
  hideCursorWhileTyping
/>

Callbacks

<Typewriter
  text={["First.", "Second."]}
  onSentenceComplete={(sentence, index) =>
    console.log("finished:", sentence, index)
  }
/>

Demo

import { TypewriterDemo } from "@/components/demos/typewriter-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <TypewriterDemo />
  </div>
);

Props

textstring | string[] — the sentence(s) to type. Required.
effectPer-character reveal animation: "blur" (default), "fade", "rise", "pop", "glitch", or "none". Pure-CSS keyframes — no dependency.
asElement rendered as the root — "div" by default (e.g. "h1", "span").
typingSpeedms per character while typing. Default 50.
initialDelayms before the very first character. Default 0.
pauseDurationms a finished sentence is held before deleting. Default 2000.
deletingSpeedms per character while deleting. Default 30.
loopCycle through the sentences forever. Default true — with false it stops after the last sentence.
showCursor / cursorCharacter / cursorBlinkDuration / cursorClassNameCursor visibility, glyph ("|" by default), blink cycle in seconds (0.5) and extra classes.
hideCursorWhileTypingHide the cursor during typing/deleting, show it only while paused. Default false.
textColorsstring[] — per-sentence colors, cycled by sentence index.
variableSpeed{ min, max } — randomize each keystroke's delay in that range, overriding typingSpeed.
onSentenceComplete(sentence, index) => void — fired every time a sentence finishes and is about to advance.
startOnVisibleWait until the component is scrolled into view before typing. Default false.
reverseModeType each sentence backwards. Default false.
…div propsclassName and all native attributes are forwarded to the root element.

Notes

  • The whole animation is one setTimeout-driven effect — typing, pausing, deleting and advancing are a single state machine, so unmounting always cleans up with clearTimeout.
  • The cursor blink and every reveal effect (blur/rise/pop/glitch) are pure CSS keyframes — no GSAP or motion dependency.
  • With an effect set, each character is its own inline-block span so its one-shot animation fires only for the newest letter; effect="none" renders a single text node with no per-character overhead.
  • startOnVisible gates the loop behind an IntersectionObserver with a 10% threshold.
  • variableSpeed re-rolls the delay for every keystroke, which reads as human typing.
  • With loop=false and a single string, the text types once and stays put — good for hero headlines.
  • The component renders inline-block with whitespace-pre-wrap, so it sits naturally inside headings and paragraphs.

Source code

The complete typewriter.tsx — toggle TSX / JSX to copy it in either language.

Source code
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ElementType,
  type HTMLAttributes,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * How each freshly-typed character reveals itself. Pure-CSS keyframes (defined
 * in globals.css as .hui-fx-*), so the component stays zero-dependency.
 *   "none" — instant (classic typewriter) · "fade" — opacity
 *   "blur" — de-blur in · "rise" — slides up · "pop" — springy scale
 *   "glitch" — skew + jitter settle
 */
export type TypewriterEffect = "none" | "fade" | "blur" | "rise" | "pop" | "glitch";

export type TypewriterProps = {
  text: string | string[];
  effect?: TypewriterEffect;
  as?: ElementType;
  typingSpeed?: number;
  initialDelay?: number;
  pauseDuration?: number;
  deletingSpeed?: number;
  loop?: boolean;
  showCursor?: boolean;
  hideCursorWhileTyping?: boolean;
  cursorCharacter?: string | ReactNode;
  cursorBlinkDuration?: number;
  cursorClassName?: string;
  textColors?: string[];
  variableSpeed?: { min: number; max: number };
  onSentenceComplete?: (sentence: string, index: number) => void;
  startOnVisible?: boolean;
  reverseMode?: boolean;
  className?: string;
} & Omit<HTMLAttributes<HTMLElement>, "children">;

export function Typewriter({
  text,
  effect = "blur",
  as: Component = "div",
  typingSpeed = 50,
  initialDelay = 0,
  pauseDuration = 2000,
  deletingSpeed = 30,
  loop = true,
  showCursor = true,
  hideCursorWhileTyping = false,
  cursorCharacter = "|",
  cursorBlinkDuration = 0.5,
  cursorClassName = "",
  textColors = [],
  variableSpeed,
  onSentenceComplete,
  startOnVisible = false,
  reverseMode = false,
  className = "",
  ...props
}: TypewriterProps) {
  const [displayedText, setDisplayedText] = useState("");
  const [currentCharIndex, setCurrentCharIndex] = useState(0);
  const [isDeleting, setIsDeleting] = useState(false);
  const [currentTextIndex, setCurrentTextIndex] = useState(0);
  const [isVisible, setIsVisible] = useState(!startOnVisible);
  const containerRef = useRef<HTMLElement | null>(null);

  const textArray = useMemo(
    () => (Array.isArray(text) ? text : [text]),
    [text],
  );

  const getRandomSpeed = useCallback(() => {
    if (!variableSpeed) return typingSpeed;
    const { min, max } = variableSpeed;
    return Math.random() * (max - min) + min;
  }, [variableSpeed, typingSpeed]);

  const getCurrentTextColor = () => {
    if (textColors.length === 0) return undefined;
    return textColors[currentTextIndex % textColors.length];
  };

  useEffect(() => {
    if (!startOnVisible || !containerRef.current) return;
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) setIsVisible(true);
        });
      },
      { threshold: 0.1 },
    );
    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, [startOnVisible]);

  useEffect(() => {
    if (!isVisible) return;

    let timeout: ReturnType<typeof setTimeout>;
    const currentText = textArray[currentTextIndex];
    const processedText = reverseMode
      ? currentText.split("").reverse().join("")
      : currentText;

    const executeTypingAnimation = () => {
      if (isDeleting) {
        if (displayedText === "") {
          setIsDeleting(false);
          if (currentTextIndex === textArray.length - 1 && !loop) return;
          onSentenceComplete?.(textArray[currentTextIndex], currentTextIndex);
          setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
          setCurrentCharIndex(0);
        } else {
          timeout = setTimeout(() => {
            setDisplayedText((prev) => prev.slice(0, -1));
          }, deletingSpeed);
        }
      } else {
        if (currentCharIndex < processedText.length) {
          timeout = setTimeout(
            () => {
              setDisplayedText((prev) => prev + processedText[currentCharIndex]);
              setCurrentCharIndex((prev) => prev + 1);
            },
            variableSpeed ? getRandomSpeed() : typingSpeed,
          );
        } else if (textArray.length > 1 || loop) {
          timeout = setTimeout(() => {
            setIsDeleting(true);
          }, pauseDuration);
        }
      }
    };

    if (currentCharIndex === 0 && !isDeleting && displayedText === "") {
      timeout = setTimeout(executeTypingAnimation, initialDelay);
    } else {
      executeTypingAnimation();
    }

    return () => clearTimeout(timeout);
  }, [
    displayedText,
    currentCharIndex,
    isDeleting,
    isVisible,
    currentTextIndex,
    textArray,
    typingSpeed,
    deletingSpeed,
    pauseDuration,
    initialDelay,
    loop,
    reverseMode,
    variableSpeed,
    getRandomSpeed,
    onSentenceComplete,
  ]);

  const shouldHideCursor =
    hideCursorWhileTyping &&
    (currentCharIndex < textArray[currentTextIndex].length || isDeleting);

  const Tag = Component as ElementType;
  const fxClass = effect === "none" ? "" : `hui-fx-${effect}`;

  return (
    <Tag
      ref={containerRef}
      className={cn("inline-block whitespace-pre-wrap", className)}
      {...props}
    >
      <span style={{ color: getCurrentTextColor() }}>
        {effect === "none"
          ? displayedText
          : displayedText.split("").map((char, i) => (
              <span
                key={i}
                className={fxClass}
                style={{ display: "inline-block", whiteSpace: "pre" }}
              >
                {char}
              </span>
            ))}
      </span>
      {showCursor && (
        <span
          className={cn(
            "ml-0.5 inline-block",
            shouldHideCursor && "hidden",
            cursorClassName,
          )}
          style={{
            animation: `hui-blink ${cursorBlinkDuration}s step-end infinite`,
          }}
        >
          {cursorCharacter}
        </span>
      )}
    </Tag>
  );
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...