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.
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/typewriterHow 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
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.
"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.