Text Animationsv0.1.0
Typewriter
Types, deletes and cycles phrases with a blinking caret.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/typewriter.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/typewriter/typewriter.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration } from "@/lib/motion"; export interface TypewriterProps extends Omit<ComponentProps<"span">, "children"> { /** Phrases to type, in order. */ phrases: string[]; /** Milliseconds per typed character; each keystroke varies a little so it feels human. @default 60 */ typeSpeed?: number; /** Milliseconds per deleted character. @default 30 */ deleteSpeed?: number; /** Milliseconds a finished phrase stays before it's deleted. @default 1600 */ pause?: number; /** Milliseconds before the first keystroke. @default 400 */ startDelay?: number; /** Cycle forever. When false, typing stops once the last phrase is typed. @default true */ loop?: boolean; /** Stop typing. Typing also pauses on its own while offscreen. @default false */ paused?: boolean; /** Show a blinking caret. @default true */ caret?: boolean; /** Reserve the width of the longest phrase so surrounding text never shifts. @default false */ reserveSpace?: boolean; /** Called each time a phrase has been fully typed. */ onPhraseTyped?: (index: number) => void; } type Phase = "typing" | "holding" | "deleting" | "done"; function graphemes(value: string) { if (typeof Intl !== "undefined" && "Segmenter" in Intl) { return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment); } return Array.from(value); } export function Typewriter({ phrases, typeSpeed = 60, deleteSpeed = 30, pause = 1600, startDelay = 400, loop = true, paused = false, caret = true, reserveSpace = false, onPhraseTyped, className, ...props }: TypewriterProps) { const reduceMotion = useReducedMotion(); const visualRef = useRef<HTMLSpanElement>(null); const inView = useInView(visualRef, { amount: 0.2 }); const [state, setState] = useState<{ index: number; length: number; phase: Phase; started: boolean }>({ index: 0, length: 0, phase: "typing", started: false, }); const typedRef = useRef(onPhraseTyped); useEffect(() => { typedRef.current = onPhraseTyped; }); const count = phrases.length; const phrase = phrases[state.index % Math.max(count, 1)] ?? ""; useEffect(() => { if (paused || !inView || count === 0 || state.phase === "done") return; const { index, length, phase, started } = state; const chars = graphemes(phrase); let wait = 0; let next = state; if (phase === "typing") { const full = chars.length; if (reduceMotion) { // Whole phrases appear at once and crossfade instead of being typed. wait = started ? 0 : startDelay; next = { index, length: full, phase: "holding", started: true }; } else { const previous = chars[length - 1] ?? ""; // Keystrokes vary ±40%, with a beat after punctuation. wait = !started ? startDelay : typeSpeed * (0.6 + Math.random() * 0.8) + (/[,.!?;:]/.test(previous) ? typeSpeed * 3 : 0); next = length + 1 >= full ? { index, length: full, phase: "holding", started: true } : { index, length: length + 1, phase, started: true }; } if (next.phase === "holding" && !loop && index === count - 1) next = { ...next, phase: "done" }; } else if (phase === "holding") { wait = pause; next = reduceMotion ? { index: (index + 1) % count, length: 0, phase: "typing", started } : { index, length, phase: "deleting", started }; } else if (phase === "deleting") { wait = length > 0 ? deleteSpeed : typeSpeed * 4; next = length > 0 ? { index, length: length - 1, phase, started } : { index: (index + 1) % count, length: 0, phase: "typing", started }; } const timer = window.setTimeout(() => { setState(next); if (next.phase !== phase && (next.phase === "holding" || next.phase === "done")) typedRef.current?.(index); }, wait); return () => window.clearTimeout(timer); }, [state, phrase, count, paused, inView, reduceMotion, typeSpeed, deleteSpeed, pause, startDelay, loop]); const typed = graphemes(phrase).slice(0, state.length).join(""); // The caret holds steady while keys are moving and blinks while waiting (never under reduced motion). const blinking = !reduceMotion && (state.phase === "holding" || state.phase === "done" || !state.started || paused); const caretMark = ( <span aria-hidden="true" className="ml-[0.06em] inline-block h-[1em] w-[0.08em] min-w-[2px] rounded-[1px] align-[-0.12em]" /> ); return ( <span className={cn("inline-grid", className)} {...props}> <span className="sr-only select-none">{phrases.join(", ")}</span> {reserveSpace ? phrases.map((phrase, i) => ( <span key={i} aria-hidden="true" className="invisible col-start-1 row-start-1 select-none whitespace-pre"> {phrase} {caret ? caretMark : null} </span> )) : null} <span ref={visualRef} aria-hidden="true" className="col-start-1 row-start-1 whitespace-pre-wrap"> <motion.span key={reduceMotion ? state.index : "typed"} initial={reduceMotion ? { opacity: 0 } : false} animate={{ opacity: 1 }} transition={{ duration: duration.base }} > {typed} </motion.span> {caret ? ( <motion.span key={blinking ? "blink" : "solid"} className="ml-[0.06em] inline-block h-[1em] w-[0.08em] min-w-[2px] rounded-[1px] bg-current align-[-0.12em]" initial={{ opacity: 1 }} animate={blinking ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }} transition={blinking ? { duration: 1.05, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: "linear" } : { duration: 0 }} /> ) : null} </span> </span> ); }
Usage
import { Typewriter } from "@/components/ui/typewriter/typewriter";
export function Hero() {
return (
<h1 className="text-4xl font-semibold">
Interfaces that feel <Typewriter phrases={["alive", "effortless", "inevitable"]} />
</h1>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
phrases* | string[] | – | Phrases to type, in order. |
typeSpeed | number | 60 | Milliseconds per typed character; each keystroke varies a little so it feels human. |
deleteSpeed | number | 30 | Milliseconds per deleted character. |
pause | number | 1600 | Milliseconds a finished phrase stays before it's deleted. |
startDelay | number | 400 | Milliseconds before the first keystroke. |
loop | boolean | true | Cycle forever. When false, typing stops once the last phrase is typed. |
paused | boolean | false | Stop typing. Typing also pauses on its own while offscreen. |
caret | boolean | true | Show a blinking caret. |
reserveSpace | boolean | false | Reserve the width of the longest phrase so surrounding text never shifts. |
onPhraseTyped | (index: number) => void | – | Called each time a phrase has been fully typed. |
Also accepts every native <span> attribute, including ref and className.
- Keystrokes vary by ±40% and pause briefly after punctuation, so typing reads as human rather than mechanical.
- Use `reserveSpace` when the typewriter sits mid-sentence: the line keeps the width of the longest phrase and the words after it never shift.
Accessibility
- Screen readers read every phrase once, from a visually hidden copy; the half-typed text and caret are aria-hidden, so nothing is announced keystroke by keystroke.
- Typing pauses while the text is offscreen, and `paused` stops it entirely if you need a pause control for long-running loops.
- With reduced motion enabled, whole phrases crossfade instead of being typed and the caret stops blinking.