# Typewriter

Types each word with a human rhythm, holds it, deletes it and moves on. Screen readers hear the full list once.

**Category:** Text  
**Exports:** `Typewriter`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/typewriter.json
```
The file lands at `components/ui/typewriter.tsx`; import from `@/components/ui/typewriter`.

## API — Typewriter
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `words` | `string[]` | — |  |
| `typeSpeed` | `number` | `70` | ms per typed character. |
| `deleteSpeed` | `number` | `40` | ms per deleted character. |
| `pause` | `number` | `1400` |  |
| `loop` | `boolean` | `true` |  |

## Example — Typewriter

```tsx
import { Typewriter } from "@/components/ui/typewriter";

export default function TypewriterDemo({
  typeSpeed = 70,
  deleteSpeed = 40,
  pause = 1400,
  loop = true,
}: {
  typeSpeed?: number;
  deleteSpeed?: number;
  pause?: number;
  loop?: boolean;
}) {
  return (
    <h3 className="text-center text-3xl font-semibold tracking-tight">
      Build interfaces that
      <br />
      <Typewriter
        words={["feel physical.", "bounce back.", "glow ember.", "respect motion."]}
        typeSpeed={typeSpeed}
        deleteSpeed={deleteSpeed}
        pause={pause}
        loop={loop}
        className="text-ember"
      />
    </h3>
  );
}
```

## Source — components/ui/typewriter.tsx

```tsx
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type TypewriterProps = {
  words: string[];
  /** ms per typed character. */
  typeSpeed?: number;
  /** ms per deleted character. */
  deleteSpeed?: number;
  /** ms to hold a finished word. */
  pause?: number;
  loop?: boolean;
  className?: string;
  cursorClassName?: string;
};

/** Types each word, holds it, deletes it, moves on. Screen readers get the full list once. */
export function Typewriter({ words, typeSpeed = 70, deleteSpeed = 40, pause = 1400, loop = true, className, cursorClassName }: TypewriterProps) {
  const reduce = useReducedMotion();
  const [i, setI] = React.useState(0);
  const [text, setText] = React.useState(reduce ? words[0] : "");
  const [deleting, setDeleting] = React.useState(false);

  React.useEffect(() => {
    if (reduce) {
      const t = window.setInterval(() => setI((n) => (n + 1) % words.length), pause + 1000);
      return () => window.clearInterval(t);
    }
    const word = words[i % words.length];
    const last = !loop && i === words.length - 1;
    let t: number;
    if (!deleting && text === word) {
      if (last) return;
      t = window.setTimeout(() => setDeleting(true), pause);
    } else if (deleting && text === "") {
      setDeleting(false);
      setI((n) => (n + 1) % words.length);
      return;
    } else {
      t = window.setTimeout(
        () => setText(deleting ? word.slice(0, text.length - 1) : word.slice(0, text.length + 1)),
        deleting ? deleteSpeed : typeSpeed + Math.random() * 40,
      );
    }
    return () => window.clearTimeout(t);
  }, [text, deleting, i, words, typeSpeed, deleteSpeed, pause, loop, reduce]);

  return (
    <span className={cn("inline-flex items-baseline", className)}>
      <span className="sr-only">{words.join(", ")}</span>
      <span aria-hidden className="whitespace-pre">
        {reduce ? words[i % words.length] : text}
      </span>
      <span aria-hidden className={cn("ml-0.5 inline-block h-[1em] w-[0.08em] translate-y-[0.12em] animate-pulse bg-ember", cursorClassName)} />
    </span>
  );
}
```