Skip to content

Components / Text

Typewriter

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.

Live demo

$ npx astro add moonarc

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 116 · not Firefox · Safari 18; needs display-animation. Elsewhere: every character still types in turn, held at zero size by the font-size in the same keyframes until then (Firefox).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Typewriter from '@moonarc/core/Typewriter';
---
<Typewriter text="npx astro add moonarc" speed={55} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/typewriter.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Typewriter.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>

Props

PropTypeDefaultDescription
textstringnoneThe text to type.
asHTMLTag'span'Element to render.
speednumber55Milliseconds per character. 40–70 reads as typing; below 30 reads as printing.
delaynumber0Delay before the first character, ms.
cursorbooleantrueShow the caret.

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.

Why it is built this way

Replaces: TextType (React Bits) · astro-typewriter · typed.js for single strings. See the migration table.

Cycles through a list of words in place.

newly · Chrome 85 · Firefox 128 · Safari 16.4always

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown