TextType
// preview
Text
Typing speed (ms/char)
Deleting speed (ms/char)
Hold before delete (ms)
Humanize
Loop
Cursor
// source
TSJS
TailwindCSS
/*!
* TextType, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/text-type
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";
export interface TextTypeProps {
/** One sentence or a cycle of sentences. */
text: string | string[];
/** Ms per typed character. */
typingSpeed?: number;
/** Ms per deleted character. */
deletingSpeed?: number;
/** Ms the finished sentence holds before deleting. */
pauseDuration?: number;
/** Ms before the first keystroke. */
initialDelay?: number;
/** Cycle through the sentences forever; off types the last one and stops. */
loop?: boolean;
/** 0-1: random per-keystroke timing jitter, so typing reads as human. */
humanize?: number;
showCursor?: boolean;
cursorCharacter?: string;
/** Fired each time a sentence finishes typing. */
onSentenceComplete?: (sentence: string, index: number) => void;
className?: string;
cursorClassName?: string;
style?: CSSProperties;
}
/**
* A typewriter that types, holds, deletes, and cycles through sentences.
* Characters land straight into the DOM via a ref (no React re-render per
* keystroke), timing is humanized with jitter and punctuation pauses, and
* the cursor only blinks while the typist is idle, like a real caret.
* Zero dependencies, honors prefers-reduced-motion.
*/
export default function TextType({
text,
typingSpeed = 65,
deletingSpeed = 32,
pauseDuration = 2000,
initialDelay = 250,
loop = true,
humanize = 0.4,
showCursor = true,
cursorCharacter = "|",
onSentenceComplete,
className = "",
cursorClassName = "",
style,
}: TextTypeProps) {
const textRef = useRef<HTMLSpanElement>(null);
const cursorRef = useRef<HTMLSpanElement>(null);
const onSentenceCompleteRef = useRef(onSentenceComplete);
onSentenceCompleteRef.current = onSentenceComplete;
const sentences = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);
useEffect(() => {
const target = textRef.current;
if (!target || sentences.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
target.textContent = sentences[0];
return;
}
let timer: ReturnType<typeof setTimeout>;
let blink: Animation | null = null;
let idleTimer: ReturnType<typeof setTimeout>;
let cancelled = false;
// The caret stays solid while keystrokes are landing and starts
// blinking only after a short idle beat, like a real text editor.
const restBlink = () => {
blink?.cancel();
blink = null;
clearTimeout(idleTimer);
const cursor = cursorRef.current;
if (!cursor) return;
cursor.style.opacity = "1";
idleTimer = setTimeout(() => {
blink = cursor.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 1000,
iterations: Infinity,
easing: "steps(2, jump-none)",
});
}, 220);
};
const jitter = (base: number) =>
base * (1 + (Math.random() * 2 - 1) * Math.min(Math.max(humanize, 0), 1));
const schedule = (fn: () => void, ms: number) => {
timer = setTimeout(() => {
if (!cancelled) fn();
}, ms);
};
let sentenceIndex = 0;
let charIndex = 0;
const typeNext = () => {
const sentence = [...sentences[sentenceIndex]];
if (charIndex < sentence.length) {
charIndex += 1;
target.textContent = sentence.slice(0, charIndex).join("");
restBlink();
const char = sentence[charIndex - 1];
// Humans hesitate after punctuation and word boundaries.
const pause = /[.,!?;:]/.test(char) ? 3.2 : char === " " ? 1.6 : 1;
schedule(typeNext, jitter(typingSpeed) * pause);
return;
}
onSentenceCompleteRef.current?.(sentences[sentenceIndex], sentenceIndex);
const isLast = sentenceIndex === sentences.length - 1;
if (isLast && !loop) return;
schedule(deleteNext, pauseDuration);
};
const deleteNext = () => {
const sentence = [...sentences[sentenceIndex]];
if (charIndex > 0) {
charIndex -= 1;
target.textContent = sentence.slice(0, charIndex).join("");
restBlink();
schedule(deleteNext, jitter(deletingSpeed));
return;
}
sentenceIndex = (sentenceIndex + 1) % sentences.length;
schedule(typeNext, jitter(typingSpeed) * 3);
};
target.textContent = "";
restBlink();
schedule(typeNext, initialDelay);
return () => {
cancelled = true;
clearTimeout(timer);
clearTimeout(idleTimer);
blink?.cancel();
};
}, [sentences, typingSpeed, deletingSpeed, pauseDuration, initialDelay, loop, humanize]);
return (
<span aria-label={sentences[0]} role="text" className={`inline-block ${className}`} style={style}>
<span ref={textRef} aria-hidden="true" className="whitespace-pre-wrap" />
{showCursor ? (
<span ref={cursorRef} aria-hidden="true" className={`inline-block ${cursorClassName}`}>
{cursorCharacter}
</span>
) : null}
</span>
);
}
// install
Install the TextType component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/TextType-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.