# Typewriter (Moonarc)

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle. Pure CSS: every character is a span whose display animates on its own delay.

- Import: `import Typewriter from '@moonarc/core/Typewriter'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/typewriter.json`
- Tier A · category text · trigger load
- Readout: `<Typewriter speed={45}>`
- Browser support: limited (Chrome 116 · not Firefox · Safari 18); elsewhere: every character still types in turn, held at zero size by the font-size in the same keyframes until then (Firefox)
- Measured cost: 0 B JS (CSS 4.8 kB raw)
- Page: https://moonarc.dev/components/typewriter/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text to type. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `speed` | `number` | `55` | Milliseconds per character. 40–70 reads as typing; below 30 reads as printing. |
| `delay` | `number` | `0` | Delay before the first character, ms. |
| `cursor` | `boolean` | `true` | Show the caret. |

## Usage

```astro
<Typewriter text="npx astro add moonarc" speed={55} />
```

## Reduced motion

The full text is shown immediately and the caret does not blink.

## With ClientRouter

CSS-only; every navigation renders fresh elements whose animations start on their own.

## Craft

- Per-character display animation means proportional fonts type correctly; width-based tricks only work in monospace.
- The reveal is timed linear so the engine interpolates it. A stepped keyframe landing more than 1 000 ms into a page reaches computed style but never layout in WebKit 26.6, and the character keeps display: inline with no box.
- Words are inline-block so a line breaks between words while typing; only a word longer than the line itself (a URL on a phone) breaks inside, where it would otherwise run out of its box.
- The caret is solid during typing and blinks only once idle, like a real terminal.
- A visually hidden span (.ma-sr) carries the whole string; the characters are aria-hidden.
- Browsers that cannot animate display still type: the font-size in the same keyframes holds each character at zero size until its turn.
- A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent types as one keystroke. The element takes dir="auto", so Latin text on a right-to-left page still types left to right.

## Replaces

- TextType (React Bits)
- astro-typewriter
- typed.js for single strings

## Source

```astro
---
/**
 * Typewriter — per-character typing in any font, zero JS.
 *
 * Each character is a span whose `display` animates from none to inline on
 * its own delay, so the caret at the end of the text sits after the last
 * visible character without measuring anything. Browsers that cannot animate
 * `display` still type through the `font-size` beside it (below); reduced
 * motion shows the text immediately.
 *
 * The `font-size` beside `display` in the keyframes, and the `linear` timing,
 * are what make the reveal arrive: a *stepped* keyframe that lands more than a
 * second after the document starts never reaches layout in WebKit 26.6 — the
 * character keeps a computed `display: inline` and is never given a box. An
 * interpolated one does. Measured in all three engines by
 * `tests/site/_probe-typewriter.mjs`; the record is docs/typewriter-webkit.md.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text to type. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Milliseconds per character. */
  speed?: number;
  /** Delay before the first character, ms. */
  delay?: number;
  /** Show the caret. */
  cursor?: boolean;
}

const { text, as: Tag = 'span', speed = 55, delay = 0, cursor = true, class: className, style, ...rest } = Astro.props;

// Words are inline-block so lines break between words, not inside one (only a
// word longer than the line breaks); the trailing space belongs to the word so
// it types as one keystroke. A character is a grapheme, not a code point: an
// emoji with a skin tone or a letter with a combining accent types as one.
const words: string[] = text.split(/(?<=\s)/);
const graphemes = (s: string) => Array.from(new Intl.Segmenter().segment(s), (g) => g.segment);
let index = 0;
const total = graphemes(text).length;
const vars = [`--ma-type-n:${total}`, `--ma-speed:${speed}ms`, delay ? `--ma-type-delay:${delay}ms` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// dir="auto" below: the words are inline-blocks, which bidi orders by the page's direction, so Latin text on a
// right-to-left page typed its words back to front. The element takes its direction from its own text instead.
---

<Tag
  class:list={['ma-type', className]}
  data-cursor={cursor ? '' : undefined}
  dir="auto"
  style={vars}
  {...rest}
>
  {
    words.map((word) => (
      <span class="ma-type__word" aria-hidden="true">
        {graphemes(word).map((ch) => (
          <span class="ma-type__char" style={`--ma-i:${index++}`}>
            {ch}
          </span>
        ))}
      </span>
    ))
  }
  <span class="ma-sr">{text}</span>
</Tag>

<style is:global>
  @layer components {
    /* a word is an inline-block as wide as its text, so it moves to the next line whole; only a word longer than the
       line itself (a URL, a package path on a phone) breaks inside, where nowrap used to run it out of its box */
    :where(.ma-type__word) {
      display: inline-block;
      overflow-wrap: anywhere;
    }
    :where(.ma-type__char) {
      white-space: pre;
      animation: ma-type-char 1ms linear both;
      animation-delay: calc(var(--ma-type-delay, 0ms) + var(--ma-i) * var(--ma-speed, 55ms));
    }
    /* the caret rides on the last word, not after it: a word is an inline-block, and a caret beside one could wrap to a
       line of its own on a narrow screen; inside the word's nowrap box it stays with the last character */
    :where(.ma-type[data-cursor] > .ma-type__word:nth-last-child(2))::after {
      content: '';
      display: inline-block;
      width: 0.08em;
      min-width: 1.5px;
      height: 1em;
      margin-inline-start: 0.08em;
      vertical-align: -0.12em;
      background: currentColor;
      /* solid while typing, blinking once idle */
      animation: ma-type-blink 1s step-end infinite;
      animation-delay: calc(var(--ma-type-delay, 0ms) + var(--ma-type-n) * var(--ma-speed, 55ms));
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-type__char) {
        animation: none;
      }
      :where(.ma-type[data-cursor] > .ma-type__word:nth-last-child(2))::after {
        animation: none;
      }
    }
  }

  /* font-size rides along so the reveal is interpolated, not stepped: a stepped one is
     dropped after the document's first second in WebKit (see the component's comment) */
  @keyframes ma-type-char {
    from {
      display: none;
      font-size: 0;
    }
    to {
      display: inline;
      font-size: 1em;
    }
  }
  @keyframes ma-type-blink {
    50% {
      opacity: 0;
    }
  }
</style>

```
