typingAnimation
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call typingAnimation() with no arguments for a working demo, or edit the code below live.
Implementation notes
Single string or phrase-list cycling (type -> pause -> delete faster -> next phrase, looping when loop is set) via a chained-setTimeout state machine, same technique this package's terminal() block already uses for its typed lines. Grapheme-safe slicing via Intl.Segmenter (falls back to Array.from) so multi-byte characters/emoji never split mid-glyph, per the spec's own research note. Default typing speed 100ms/char, deleting speed ~2x faster, 1000ms phrase pause, matching the spec's documented defaults. Cursor blink is a CSS steps() opacity keyframe (matches spec's 'looping CSS opacity keyframe' description exactly). One real deviation: the three cursor shapes (line/block/underscore) are rendered as themed text glyphs ('▏' / '█' / '_') sized by the inherited font-size, rather than a literal drawn box with explicit width/height -- this follows this package's own established idiom (documented in terminal.ts's cursor/traffic-light glyphs) of using a solid-fill text glyph + color instead of a backgroundColor box specifically so it doesn't trip the doctor's tone-background-inherit rule, which exists to keep surface-shifting centralized on dataTone containers. Visually equivalent, implemented differently.
Status: ported · Reference: Magic UI original
// magicui "Typing Animation" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A classic
// typewriter effect: one grapheme revealed per tick via a chained `setTimeout`
// (a discrete step animation, not eased — each frame is one whole character),
// with a trailing cursor glyph that blinks via a looping CSS opacity keyframe.
// Given a list of phrases instead of one string, it types a phrase, pauses,
// deletes it (faster than it typed), then types the next, cycling forever
// when `loop` is set — the same chained-timeout technique this package's
// `terminal()` block uses for its own typed command lines, generalized to
// support delete/cycle through multiple phrases.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
export type TypingCursorStyle = "line" | "block" | "underscore";
export type TypingAnimationTag = "span" | "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export interface TypingAnimationProps {
/** Text to type, or a list of phrases to cycle through. Defaults to a short demo phrase. */
text?: string | string[];
/** ms per character while typing. Defaults to `100`. */
typingSpeed?: number;
/** ms per character while deleting. Defaults to roughly twice as fast as typing (`typingSpeed / 2`). */
deletingSpeed?: number;
/** ms a fully-typed phrase is held before deleting starts. Only relevant with multiple phrases. Defaults to `1000`. */
pauseDuration?: number;
/** ms before the very first character types. Defaults to `0`. */
startDelay?: number;
/** Cycles back to the first phrase after the last. Only relevant with multiple phrases. Defaults to `true`. */
loop?: boolean;
/** Shows the trailing cursor glyph. Defaults to `true`. */
showCursor?: boolean;
/** Blinks the cursor. When `false`, the cursor is shown static (solid). Defaults to `true`. */
cursorBlink?: boolean;
/** Cursor glyph shape. Defaults to `"line"`. */
cursorStyle?: TypingCursorStyle;
/** Waits until the wrapper scrolls into view before typing starts. Defaults to `false`. */
startOnView?: boolean;
/** Wrapping element tag. Defaults to `"span"`. */
as?: TypingAnimationTag;
/** Passthrough style merged onto the outer wrapper. */
style?: StyleObject;
}
const CURSOR_KEYFRAMES = { "0%,49%": { opacity: 1 }, "50%,100%": { opacity: 0 } };
const CURSOR_ANIMATION_NAME = `typing-animation-cursor-${hashString(JSON.stringify(CURSOR_KEYFRAMES))}`;
/** Grapheme-safe split so multi-byte characters/emoji don't break mid-glyph. */
function toGraphemes(text: string): string[] {
if (typeof Intl !== "undefined" && typeof (Intl as unknown as { Segmenter?: unknown }).Segmenter === "function") {
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return Array.from(segmenter.segment(text), (entry) => entry.segment);
}
return Array.from(text);
}
// A solid-fill text glyph (not a `backgroundColor` box) so its fixed-shift
// tone reads as a text-color glyph rather than a hardcoded surface — same
// idiom as this package's `terminal()` block uses for its own cursor/traffic
// lights, and keeps the glyph's height matched to the surrounding text size
// for free (inherited font-size, no literal width/height needed).
const CURSOR_GLYPH_BY_STYLE: Record<TypingCursorStyle, string> = {
line: "▏",
block: "█",
underscore: "_",
};
function cursorGlyph(cursorStyle: TypingCursorStyle, blink: boolean): DomphyElement<"span"> {
return {
span: CURSOR_GLYPH_BY_STYLE[cursorStyle],
ariaHidden: "true",
style: {
display: "inline-block",
marginInlineStart: themeSpacing(1),
color: (listener) => themeColor(listener, "shift-9"),
animation: blink ? `${CURSOR_ANIMATION_NAME} 1s steps(1) infinite` : undefined,
[`@keyframes ${CURSOR_ANIMATION_NAME}`]: blink ? CURSOR_KEYFRAMES : undefined,
} as StyleObject,
} as DomphyElement<"span">;
}
/**
* Classic typewriter reveal: text appears one character at a time with an
* optional blinking cursor, or cycles through a list of phrases (type, pause,
* delete, next) indefinitely when `loop` is set. Call with no arguments for a
* working demo.
*/
function typingAnimation(props: TypingAnimationProps = {}): DomphyElement {
const phrases = props.text
? Array.isArray(props.text)
? props.text
: [props.text]
: ["Build with Domphy.", "No JSX. No virtual DOM.", "Just plain objects."];
const typingSpeed = props.typingSpeed ?? 100;
const deletingSpeed = props.deletingSpeed ?? Math.max(1, Math.round(typingSpeed / 2));
const pauseDuration = props.pauseDuration ?? 1000;
const startDelay = props.startDelay ?? 0;
const loop = props.loop ?? true;
const showCursor = props.showCursor ?? true;
const cursorBlink = props.cursorBlink ?? true;
const cursorStyle = props.cursorStyle ?? "line";
const startOnView = props.startOnView ?? false;
const wrapperTag = props.as ?? "span";
const phraseGraphemes = phrases.map((phrase) => toGraphemes(phrase));
const revealedText = toState("");
const outerChildren: DomphyElement[] = [
{
span: (listener) => revealedText.get(listener),
_key: "revealed",
dataTypingRevealed: "true",
style: { whiteSpace: "pre-wrap" },
},
...(showCursor ? [{ ...cursorGlyph(cursorStyle, cursorBlink), _key: "cursor" }] : []),
];
const outer = {
[wrapperTag]: outerChildren,
style: {
display: "inline-flex",
alignItems: "center",
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let phraseIndex = 0;
const typeStep = (characterIndex: number) => {
const graphemes = phraseGraphemes[phraseIndex];
revealedText.set(graphemes.slice(0, characterIndex).join(""));
if (characterIndex < graphemes.length) {
timeoutHandle = setTimeout(() => typeStep(characterIndex + 1), typingSpeed);
return;
}
// Finished typing this phrase. A single phrase (no cycling target)
// just stops here, cursor still blinking.
if (phrases.length <= 1) return;
timeoutHandle = setTimeout(() => deleteStep(graphemes.length), pauseDuration);
};
const deleteStep = (characterIndex: number) => {
const graphemes = phraseGraphemes[phraseIndex];
revealedText.set(graphemes.slice(0, characterIndex).join(""));
if (characterIndex > 0) {
timeoutHandle = setTimeout(() => deleteStep(characterIndex - 1), deletingSpeed);
return;
}
const nextPhraseIndex = (phraseIndex + 1) % phrases.length;
if (nextPhraseIndex === 0 && !loop) return; // completed the full cycle, don't wrap back
phraseIndex = nextPhraseIndex;
timeoutHandle = setTimeout(() => typeStep(0), 0);
};
const begin = () => {
timeoutHandle = setTimeout(() => typeStep(0), startDelay);
};
if (!startOnView) {
begin();
} else if (typeof IntersectionObserver !== "function") {
// No IntersectionObserver support (e.g. non-browser test runtime) —
// fail open and start immediately rather than never playing.
begin();
} else {
const element = node.domElement as Element;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
begin();
observer.disconnect();
}
},
{ threshold: 0.1 },
);
observer.observe(element);
node.addHook("Remove", () => observer.disconnect());
}
node.addHook("Remove", () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
});
},
} as unknown as DomphyElement;
return outer;
}
export { typingAnimation };