Types and deletes a rotating list of words after a static prefix, with configurable typing speed, hold pause, accent color, and a blinking caret.
Preview
Customize
6 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add typewriterWrites components/ui/typewriter.tsx and components/styles/typewriter.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Typewriter } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Typewriter />6 );7}Props
Accessibility
A visually hidden span announces the prefix and the full word list once; the character-by-character output and caret are aria-hidden so screen readers are not spammed. Under prefers-reduced-motion the first word is shown statically with a steady caret.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/typewriter.module.css";6 7export interface TypewriterProps {8 /** Words typed and deleted in sequence */9 words?: string[];10 /** Static text rendered before the typed word */11 prefix?: string;12 /** Milliseconds per typed character (deleting runs at twice the speed) */13 speed?: number;14 /** Milliseconds to hold a completed word before deleting */15 pause?: number;16 /** Show the blinking cursor */17 cursor?: boolean;18 /** Color of the typed word and cursor */19 accentColor?: string;20 /** Optional className */21 className?: string;22}23 24const DEFAULT_WORDS = ["interfaces", "landing pages", "dashboards", "design systems"];25 26export function Typewriter({27 words = DEFAULT_WORDS,28 prefix = "Build",29 speed = 70,30 pause = 1400,31 cursor = true,32 accentColor = "#38bdf8",33 className = "",34}: TypewriterProps) {35 const prefersReducedMotion = useReducedMotion();36 const list = words.length > 0 ? words : DEFAULT_WORDS;37 const [index, setIndex] = React.useState(0);38 const [text, setText] = React.useState("");39 const [deleting, setDeleting] = React.useState(false);40 const word = list[index % list.length] ?? "";41 42 React.useEffect(() => {43 if (prefersReducedMotion) return;44 let t: ReturnType<typeof setTimeout> | undefined;45 if (!deleting && text === word) {46 t = setTimeout(() => setDeleting(true), pause);47 } else if (deleting && text === "") {48 setDeleting(false);49 setIndex((i) => (i + 1) % list.length);50 } else {51 t = setTimeout(52 () => setText(deleting ? word.slice(0, text.length - 1) : word.slice(0, text.length + 1)),53 deleting ? speed / 2 : speed,54 );55 }56 return () => {57 if (t) clearTimeout(t);58 };59 }, [text, deleting, word, list.length, speed, pause, prefersReducedMotion]);60 61 const shown = prefersReducedMotion ? list[0] : text;62 const label = `${prefix ? `${prefix} ` : ""}${list.join(", ")}`;63 64 return (65 <span66 style={{ "--mf-tw-accent": accentColor } as React.CSSProperties}67 className={`${styles.root} ${className}`.trim()}68 >69 <span className={styles.srOnly}>{label}</span>70 <span aria-hidden="true">71 {prefix ? <span className={styles.prefix}>{prefix} </span> : null}72 <span className={styles.typed}>{shown}</span>73 {cursor ? (74 <motion.span75 className={styles.cursor}76 animate={prefersReducedMotion ? { opacity: 1 } : { opacity: [1, 0] }}77 transition={78 prefersReducedMotion ? undefined : { duration: 0.6, repeat: Infinity, repeatType: "reverse" }79 }80 />81 ) : null}82 </span>83 </span>84 );85}