---
title: Typing
description: Typewriter text with a quiet caret.
group: Text
order: 32
new: true
---

Characters land one by one, hold, then erase into the next phrase. Use a single `text`, or pass `texts` with `loop`.

```tsx
import { Typing } from "@/components/motion/typing"

export function Example() {
  return (
    <Typing
      texts={["Quiet motion, typed out.", "Compose freely.", "Ship quieter UI."]}
      loop
    />
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add typing
```

## Usage

```tsx
import { Typing } from "@/components/motion/typing"
```

```tsx
<Typing
  texts={["Quiet motion, typed out.", "Compose freely.", "Ship quieter UI."]}
  loop
/>
```

## API Reference

### Typing

| Prop | Type | Default |
| --- | --- | --- |
| text | string | "" |
| texts | string[] | — |
| play | number | 0 |
| speed | number | 48 |
| deleteSpeed | number | 28 |
| pause | number | 1400 |
| loop | boolean | false |
| cursor | boolean | true |
| className | string | — |

Also accepts the other native `span` attributes. `speed`, `deleteSpeed`, and `pause` are in milliseconds. Bump `play` to restart.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  useState,
  type ComponentProps,
} from "react";
import { cn } from "@/lib/utils";

export type TypingProps = Omit<ComponentProps<"span">, "children"> & {
  /** Single phrase to type. */
  text?: string;
  /** Phrases to cycle through. Wins over `text` when non-empty. */
  texts?: string[];
  /** Bump to restart from the first phrase. */
  play?: number;
  /** Delay between typed characters in ms. */
  speed?: number;
  /** Delay between deleted characters in ms. */
  deleteSpeed?: number;
  /** Hold after a phrase finishes before deleting or stopping. */
  pause?: number;
  /** Delete and continue through phrases. */
  loop?: boolean;
  /** Show the caret. */
  cursor?: boolean;
};

function Typing({
  className,
  text = "",
  texts,
  play = 0,
  speed = 48,
  deleteSpeed = 28,
  pause = 1400,
  loop = false,
  cursor = true,
  ...props
}: TypingProps) {
  const phrases =
    texts && texts.length > 0 ? texts : text.length > 0 ? [text] : [""];
  const [display, setDisplay] = useState("");
  const [typing, setTyping] = useState(false);
  const timersRef = useRef<number[]>([]);

  useEffect(() => {
    for (const id of timersRef.current) window.clearTimeout(id);
    timersRef.current = [];

    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (reduced) {
      setDisplay(phrases[0] ?? "");
      setTyping(false);
      return;
    }

    const typeMs = Math.max(speed, 12);
    const eraseMs = Math.max(deleteSpeed, 10);
    const holdMs = Math.max(pause, 0);
    let phraseIndex = 0;
    let charIndex = 0;
    let deleting = false;

    setDisplay("");
    setTyping(true);

    const schedule = (fn: () => void, ms: number) => {
      const id = window.setTimeout(fn, ms);
      timersRef.current.push(id);
    };

    const tick = () => {
      const current = phrases[phraseIndex] ?? "";

      if (!deleting) {
        charIndex = Math.min(charIndex + 1, current.length);
        setDisplay(current.slice(0, charIndex));
        setTyping(true);

        if (charIndex >= current.length) {
          setTyping(false);
          if (!loop && phraseIndex >= phrases.length - 1) return;
          schedule(() => {
            deleting = true;
            setTyping(true);
            tick();
          }, holdMs);
          return;
        }

        schedule(tick, typeMs);
        return;
      }

      charIndex = Math.max(charIndex - 1, 0);
      setDisplay(current.slice(0, charIndex));
      setTyping(true);

      if (charIndex <= 0) {
        phraseIndex = (phraseIndex + 1) % phrases.length;
        deleting = false;

        if (!loop && phraseIndex === 0) {
          setDisplay(phrases[0] ?? "");
          setTyping(false);
          return;
        }

        schedule(tick, typeMs);
        return;
      }

      schedule(tick, eraseMs);
    };

    schedule(tick, typeMs);

    return () => {
      for (const id of timersRef.current) window.clearTimeout(id);
      timersRef.current = [];
    };
  }, [text, texts, play, speed, deleteSpeed, pause, loop]);

  const label = phrases.join(" ");

  return (
    <span
      data-slot="typing"
      data-typing={typing ? "true" : "false"}
      className={cn("arctis-typing inline-block whitespace-pre-wrap", className)}
      aria-label={label}
      {...props}
    >
      {display}
      {cursor ? <span className="arctis-typing-caret" aria-hidden="true" /> : null}
    </span>
  );
}

export { Typing };
```
