# Text Writer

The TextWriter component creates a typing animation effect for text, where characters appear one by one to mimic real-time typing.

- **Category:** Text
- **Docs:** https://uibeats.com/docs/text/text-writer
- **Registry:** https://uibeats.com/r/text-writer.json
- **License:** MIT
- **npm dependencies:** `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/text-writer.json
```

Or install the dependencies and copy the source below:

```bash
npm install motion
```

## When to use

For interfaces that should feel composed live: terminal output, AI responses, onboarding. Enable loop only where the text is decorative; re-typing content someone is trying to read is frustrating.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | `-` | The text to be animated (required) |
| `delay` | `number` | `0.05` | Time delay between each character (in seconds) |
| `className` | `string` | `''` | Additional CSS classes for styling |
| `animationConfig` | `AnimationProps` | `{}` | Custom Framer Motion animation configuration |
| `cursorColor` | `string` | `'currentColor'` | Color of the typing cursor |
| `cursorWidth` | `string` | `'2px'` | Width of the typing cursor |
| `cursorStyle` | `string` | `'solid'` | CSS border-style of the cursor |
| `onComplete` | `() => void` | `() => {}` | Callback function executed when typing is complete |
| `eraseOnComplete` | `boolean` | `false` | Whether to erase the text after completion |
| `eraseDelay` | `number` | `2000` | Delay before erasing starts (in milliseconds) |
| `loop` | `boolean` | `false` | Whether to loop the writing and erasing effect |

## Example usage

```tsx
import React from "react";
import TextWriter from "@/components/demo/text/text-writer";

const TextWriterUsage = () => {
  return (
    <TextWriter
      text="Welcome to UI Beats"
      className="text-md mb-4 md:text-lg"
      delay={0.1}
    />
  );
};

export default TextWriterUsage;
```

## Source

Save as `components/ui/text-writer.tsx`.

```tsx
"use client";
import React, { useEffect, useRef, useState } from "react";
import { Easing, motion, useReducedMotion } from "motion/react";

interface TextWriterProps {
  text: string;
  delay?: number;
  className?: string;
  cursorColor?: string;
  cursorWidth?: string;
  cursorStyle?: string;
  eraseOnComplete?: boolean;
  eraseDelay?: number;
  loop?: boolean;
}

const TextWriter: React.FC<TextWriterProps> = ({
  text,
  delay = 0.05,
  className = "",
  // Was `#000`: a black caret on a dark background, i.e. invisible in dark
  // mode. `currentColor` matches whatever the text beside it is using.
  cursorColor = "currentColor",
  cursorWidth = "2px",
  cursorStyle = "solid",
  eraseOnComplete = false,
  eraseDelay = 2000,
  loop = false,
}) => {
  const [displayText, setDisplayText] = useState("");
  const [isWriting, setIsWriting] = useState(true);
  const textRef = useRef<HTMLSpanElement>(null);
  const prefersReducedMotion = useReducedMotion();
  const stepEasing: Easing = (t) => Math.floor(t * 2) / 2;

  /*
   * Reduced motion gets the whole sentence at once.
   *
   * This is the one component here where the animation withholds content
   * rather than decorating it: mid-effect, the DOM genuinely contains half a
   * word. Typing it out character by character (and, with `loop`, deleting it
   * again forever) is unreadable for anyone who asked the browser to stop
   * moving things, so they get the finished text and no cursor.
   *
   * Derived during render rather than pushed into state from the effect: the
   * typing state is simply not consulted, so there is nothing to synchronise
   * and no cascading render to trigger.
   */
  const shownText = prefersReducedMotion ? text : displayText;
  const showCursor = isWriting && !prefersReducedMotion;

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;
    let currentIndex = 0;

    if (prefersReducedMotion) return;

    const writeText = () => {
      if (currentIndex <= text.length) {
        setDisplayText(text.slice(0, currentIndex));
        currentIndex++;
        timeoutId = setTimeout(writeText, delay * 1000);
      } else {
        setIsWriting(false);
        if (eraseOnComplete || loop) {
          timeoutId = setTimeout(eraseText, eraseDelay);
        }
      }
    };

    const eraseText = () => {
      setIsWriting(true);
      if (currentIndex > 0) {
        currentIndex--;
        setDisplayText(text.slice(0, currentIndex));
        timeoutId = setTimeout(eraseText, delay * 1000);
      } else {
        if (loop) {
          currentIndex = 0;
          timeoutId = setTimeout(writeText, delay * 1000);
        } else {
          setIsWriting(false);
        }
      }
    };

    writeText();

    return () => clearTimeout(timeoutId);
  }, [text, delay, eraseOnComplete, eraseDelay, loop, prefersReducedMotion]);

  return (
    <span className={`inline-flex items-center ${className}`}>
      <span ref={textRef}>{shownText}</span>
      {showCursor && (
        <motion.span
          style={{
            width: cursorWidth,
            height: "1em",
            backgroundColor: cursorColor,
            display: "inline-block",
            borderStyle: cursorStyle,
            marginLeft: "1px",
          }}
          animate={{ opacity: [1, 0] }}
          transition={{
            duration: 0.8,
            repeat: Infinity,
            ease: stepEasing,
          }}
        />
      )}
    </span>
  );
};

export default TextWriter;
```
