Rotating Text
TextCycles through a list of words in place.
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.
$ npx astro add moonarc
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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).
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Typewriter from '@moonarc/core/Typewriter';
---
<Typewriter text="npx astro add moonarc" speed={55} />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/typewriter.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
The full text is shown immediately and the caret does not blink.
ClientRouterCSS-only; every navigation renders fresh elements whose animations start on their own.
Replaces: TextType (React Bits) · astro-typewriter · typed.js for single strings. See the migration table.
Cycles through a list of words in place.
Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown