# Typewriter Text

> Typewriter text that types, deletes and cycles through words.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/typewriter-text
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/typewriter-text.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/typewriter-text-demo.tsx"
import { TypewriterText } from "@/components/typewriter-text"

export default function TypewriterTextDemo() {
  return (
    <h2 className="max-w-2xl text-center text-4xl font-semibold tracking-tight sm:text-5xl">
      Build interfaces that feel{" "}
      <TypewriterText words={["fast.", "polished.", "accessible.", "yours."]} className="text-orange-500" cursorColor="#f97316" />
    </h2>
  )
}
```

## Source

```tsx title="components/typewriter-text.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

const keyframes = `@keyframes typewriter-caret{0%,45%{opacity:1}55%,100%{opacity:0}}`

const reducedMotion = "(prefers-reduced-motion: reduce)"
const prefersReducedMotion = () => window.matchMedia(reducedMotion).matches
const subscribeReducedMotion = (onChange: () => void) => {
  const query = window.matchMedia(reducedMotion)
  query.addEventListener("change", onChange)
  return () => query.removeEventListener("change", onChange)
}

export interface TypewriterTextProps extends React.ComponentProps<"span"> {
  words: string[]
  /** Milliseconds per typed character */
  typeSpeed?: number
  /** Milliseconds per deleted character */
  deleteSpeed?: number
  /** Milliseconds a finished word stays before it is deleted */
  pause?: number
  loop?: boolean
  showCursor?: boolean
  cursorColor?: string
}

export function TypewriterText({
  words,
  typeSpeed = 70,
  deleteSpeed = 40,
  pause = 1600,
  loop = true,
  showCursor = true,
  cursorColor = "currentColor",
  className,
  ...props
}: TypewriterTextProps) {
  const [index, setIndex] = React.useState(0)
  const [length, setLength] = React.useState(0)
  const [deleting, setDeleting] = React.useState(false)
  const word = words[index % words.length] ?? ""
  const last = !loop && index === words.length - 1
  const reduced = React.useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, () => false)

  React.useEffect(() => {
    if (reduced) return
    let delay = deleting ? deleteSpeed : typeSpeed
    if (!deleting && length === word.length) {
      if (last) return
      delay = pause
    }
    const timer = setTimeout(() => {
      if (!deleting && length === word.length) setDeleting(true)
      else if (deleting && length === 0) {
        setDeleting(false)
        setIndex((i) => (i + 1) % words.length)
      } else setLength((n) => n + (deleting ? -1 : 1))
    }, delay)
    return () => clearTimeout(timer)
  }, [reduced, length, deleting, word, last, words.length, typeSpeed, deleteSpeed, pause])

  return (
    <span className={cn("whitespace-pre", className)} {...props}>
      <span className="sr-only">{words.join(", ")}</span>
      <span aria-hidden>{reduced ? word : word.slice(0, length)}</span>
      {showCursor && (
        <>
          <style href="typewriter-text" precedence="default">
            {keyframes}
          </style>
          <span
            aria-hidden
            className="ml-0.5 inline-block h-[1em] w-[0.08em] min-w-0.5 translate-y-[0.12em] rounded-full"
            style={{ background: cursorColor, animation: "typewriter-caret 1s steps(1) infinite" }}
          />
        </>
      )}
    </span>
  )
}
```
