textAnimate
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call textAnimate() with no arguments for a working demo, or edit the code below live.
Implementation notes
Split modes (character/word/line/text), 10 animation presets (fade/blur/slide/scale variants), per-mode default stagger (30ms char / 50ms word / 60ms line+whole), 300ms default duration, start delay, startOnView, once, and the accessibility toggle are all implemented. Hand-rolled on the raw Web Animations API (guarded by typeof element.animate === 'function', same convention as this package's other WAAPI-driven blocks) rather than the shared motion() patch, specifically so the optional exit transition can use an independently-computed, correctly-reversed per-segment delay (motion() only exposes one shared delay for both enter and exit). 'className'/'segment className' from the spec are exposed as Domphy's own style/segmentStyle passthrough props instead -- this framework has no CSS-class concept, and every other block in this package makes the same substitution. 'line' mode splits on literal \n in the text string; there is no browser line-wrap measurement pass, so soft-wrap-aware line splitting is out of scope (consistent with how other blocks in this package avoid layout-measurement-dependent features). 'Replay whenever the text content changes' is achieved by typing text as ValueOrState<string> and keying every segment off the live text value, so a text change produces fresh keys, a full remount, and a fresh entrance stagger for free. Accessibility uses the sr-only-text + aria-hidden-decoration pattern (matches the spec's own DOM sketch). jsdom (the test runtime) has no Web Animations API, so entrance/exit tweens no-op there; one test stubs HTMLElement.prototype.animate to verify the enter tween is actually invoked.
Status: ported · Reference: Magic UI original
// magicui "Text Animate" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Text that
// reveals itself through a staggered per-segment motion, splitting the
// content by character, word, line, or leaving it whole, and animating each
// segment from a hidden keyframe (offset/blurred/scaled) into its resting
// keyframe with a small incremental delay between consecutive segments — the
// same "stagger children" technique used throughout this package's other
// entrance effects (`blurFade`, `terminal`'s fade lines), driven directly by
// the Web Animations API instead of a bundled animation library.
//
// Only `text` is exposed as a `ValueOrState` — passing a `State<string>`
// gives "replay whenever the text content changes" for free: every segment's
// `_key` embeds the current text value, so a text change produces entirely
// new keys, the old segments unmount (playing the reversed-stagger exit) and
// fresh ones mount (playing the entrance stagger) without any extra
// bookkeeping. `by`/`animation`/other props are fixed at construction, same
// as this package's other split-text effects (`spinningText`).
import type { DomphyElement, ElementNode, Listener, StyleObject, ValueOrState } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
export type TextAnimateBy = "character" | "word" | "line" | "text";
export type TextAnimatePreset =
| "fadeIn"
| "blurIn"
| "blurInUp"
| "blurInDown"
| "slideUp"
| "slideDown"
| "slideLeft"
| "slideRight"
| "scaleUp"
| "scaleDown";
export type TextAnimateTag = "span" | "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export interface TextAnimateProps {
/** Text content. Pass a `State<string>` for automatic replay when it changes.
* Defaults to a short demo sentence. */
text?: ValueOrState<string>;
/** How the text is split into animated segments. Defaults to `"word"`. */
by?: TextAnimateBy;
/** Which hidden→visible keyframe pair each segment animates through. Defaults to `"fadeIn"`. */
animation?: TextAnimatePreset;
/** Tween duration per segment, in ms. Defaults to `300`. */
duration?: number;
/** Delay before the first segment starts, in ms. Defaults to `0`. */
delay?: number;
/** Delay between consecutive segments, in ms. Defaults to `30` for `"character"`,
* `50` for `"word"`, `60` for `"line"`/`"text"`. */
staggerDelay?: number;
/** Waits until the wrapper scrolls into view before starting. Defaults to `false`. */
startOnView?: boolean;
/** Once triggered by scrolling into view, never re-triggers on re-entry. Only
* relevant when `startOnView` is `true`. Defaults to `true`. */
once?: boolean;
/** Wrapping element tag. Defaults to `"span"`. */
as?: TextAnimateTag;
/** Keeps the full, unsplit text readable to screen readers via a visually-hidden
* duplicate, marking the animated segments `aria-hidden`. Defaults to `true`. */
accessibility?: boolean;
/** Overrides merged onto the computed hidden keyframe. */
initialStyle?: Record<string, string | number>;
/** Overrides merged onto the computed visible (resting) keyframe. */
animateStyle?: Record<string, string | number>;
/** Overrides merged onto the exit keyframe (defaults to the hidden keyframe). */
exitStyle?: Record<string, string | number>;
/** Passthrough style merged onto every segment. */
segmentStyle?: StyleObject;
/** Passthrough style merged onto the outer wrapper. */
style?: StyleObject;
}
interface TextSegment {
text: string;
animate: boolean;
}
const DEFAULT_STAGGER_MS: Record<TextAnimateBy, number> = {
character: 30,
word: 50,
line: 60,
text: 60,
};
const PRESET_KEYFRAMES: Record<TextAnimatePreset, { hidden: Record<string, string | number>; visible: Record<string, string | number> }> = {
fadeIn: { hidden: { opacity: 0 }, visible: { opacity: 1 } },
blurIn: { hidden: { opacity: 0, filter: "blur(10px)" }, visible: { opacity: 1, filter: "blur(0px)" } },
blurInUp: {
hidden: { opacity: 0, filter: "blur(10px)", transform: "translateY(8px)" },
visible: { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" },
},
blurInDown: {
hidden: { opacity: 0, filter: "blur(10px)", transform: "translateY(-8px)" },
visible: { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" },
},
slideUp: { hidden: { opacity: 0, transform: "translateY(14px)" }, visible: { opacity: 1, transform: "translateY(0)" } },
slideDown: { hidden: { opacity: 0, transform: "translateY(-14px)" }, visible: { opacity: 1, transform: "translateY(0)" } },
slideLeft: { hidden: { opacity: 0, transform: "translateX(14px)" }, visible: { opacity: 1, transform: "translateX(0)" } },
slideRight: { hidden: { opacity: 0, transform: "translateX(-14px)" }, visible: { opacity: 1, transform: "translateX(0)" } },
scaleUp: { hidden: { opacity: 0, transform: "scale(0.5)" }, visible: { opacity: 1, transform: "scale(1)" } },
scaleDown: { hidden: { opacity: 0, transform: "scale(1.5)" }, visible: { opacity: 1, transform: "scale(1)" } },
};
const SR_ONLY_STYLE = {
position: "absolute",
width: "1px",
height: "1px",
padding: "0",
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: "0",
} as const;
/** Grapheme-safe split so multi-byte characters/emoji stay whole in `"character"` mode. */
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);
}
function splitIntoSegments(text: string, by: TextAnimateBy): TextSegment[] {
if (by === "text") return [{ text, animate: true }];
if (by === "line") return text.split("\n").map((line) => ({ text: line, animate: true }));
if (by === "character") return toGraphemes(text).map((character) => ({ text: character, animate: true }));
// "word": keep whitespace runs as their own non-animated segments so spacing
// between words survives unchanged in the rendered layout.
const tokens = text.match(/\S+|\s+/g) ?? [];
return tokens.map((token) => ({ text: token, animate: !/^\s+$/.test(token) }));
}
/**
* Text that reveals itself segment by segment (character/word/line/whole)
* with a small stagger delay between consecutive segments, animating from a
* hidden keyframe (fade/blur/slide/scale) into its resting position. Plays on
* mount by default, or once scrolled into view when `startOnView` is set.
* Call with no arguments for a working demo.
*/
function textAnimate(props: TextAnimateProps = {}): DomphyElement {
const textState = toState(props.text ?? "Domphy renders exactly what you write, nothing more.", "text-animate-text");
const by = props.by ?? "word";
const animationPreset = props.animation ?? "fadeIn";
const durationMs = props.duration ?? 300;
const startDelayMs = props.delay ?? 0;
const staggerMs = props.staggerDelay ?? DEFAULT_STAGGER_MS[by];
const startOnView = props.startOnView ?? false;
const once = props.once ?? true;
const wrapperTag = props.as ?? "span";
const accessibility = props.accessibility ?? true;
const preset = PRESET_KEYFRAMES[animationPreset] ?? PRESET_KEYFRAMES.fadeIn;
const hiddenKeyframe = { ...preset.hidden, ...(props.initialStyle ?? {}) };
const visibleKeyframe = { ...preset.visible, ...(props.animateStyle ?? {}) };
const exitKeyframe = props.exitStyle ? { ...hiddenKeyframe, ...props.exitStyle } : hiddenKeyframe;
// Shared, instance-scoped runtime state — persists across reactive
// re-renders of the segment list (e.g. when `text` is a State and changes).
const viewState = { hasEntered: !startOnView };
const segmentPlayers: Array<() => void> = [];
function buildSegmentElement(segment: TextSegment, index: number, currentText: string, animatedIndex: number, animatedCount: number): DomphyElement<"span"> {
const key = `${currentText}::${index}`;
if (!segment.animate) {
return { span: segment.text, _key: key } as DomphyElement<"span">;
}
return {
span: segment.text,
_key: key,
style: {
display: by === "line" ? "block" : "inline-block",
willChange: "transform, opacity, filter",
...(props.segmentStyle ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const element = node.domElement as HTMLElement;
const enterDelayMs = startDelayMs + animatedIndex * staggerMs;
const play = () => {
if (typeof element.animate !== "function") return;
element.animate([hiddenKeyframe as Keyframe, visibleKeyframe as Keyframe], {
duration: durationMs,
delay: enterDelayMs,
easing: "ease-out",
fill: "both",
});
};
segmentPlayers.push(play);
if (viewState.hasEntered) play();
node.addHook("Remove", () => {
const playerIndex = segmentPlayers.indexOf(play);
if (playerIndex !== -1) segmentPlayers.splice(playerIndex, 1);
});
},
_onBeforeRemove: (node: ElementNode, done: () => void) => {
const element = node.domElement as HTMLElement | null;
if (!element || typeof element.animate !== "function") {
done();
return;
}
// Reversed order: the last segment to enter is the first to leave.
const exitDelayMs = startDelayMs + (animatedCount - 1 - animatedIndex) * staggerMs;
const animation = element.animate([visibleKeyframe as Keyframe, exitKeyframe as Keyframe], {
duration: durationMs,
delay: exitDelayMs,
easing: "ease-in",
fill: "both",
});
animation.finished.then(() => done(), () => done());
},
};
}
function buildSegments(currentText: string): DomphyElement[] {
const rawSegments = splitIntoSegments(currentText, by);
const animatedCount = rawSegments.filter((segment) => segment.animate).length;
let animatedRunningIndex = 0;
return rawSegments.map((segment, index) => {
if (!segment.animate) return buildSegmentElement(segment, index, currentText, -1, animatedCount);
const animatedIndex = animatedRunningIndex;
animatedRunningIndex += 1;
return buildSegmentElement(segment, index, currentText, animatedIndex, animatedCount);
});
}
const typographyPatch = wrapperTag === "p" ? [paragraph()] : /^h[1-6]$/.test(wrapperTag) ? [heading()] : [];
const outer = {
[wrapperTag]: (listener: Listener) => {
const currentText = textState.get(listener);
const segmentElements = buildSegments(currentText);
if (!accessibility) return segmentElements;
return [
{ span: currentText, _key: "sr-only-text", style: SR_ONLY_STYLE },
{
span: segmentElements,
ariaHidden: "true",
_key: "segments",
style: by === "line" ? { display: "block" } : undefined,
},
];
},
...(typographyPatch.length ? { $: typographyPatch } : {}),
_onMount: (node: ElementNode) => {
if (typeof window === "undefined" || !startOnView) return;
if (typeof IntersectionObserver !== "function") {
// No IntersectionObserver support (e.g. non-browser test runtime) —
// fail open and play immediately rather than never playing.
viewState.hasEntered = true;
for (const play of segmentPlayers) play();
return;
}
const element = node.domElement as Element;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
if (entry.isIntersecting) {
viewState.hasEntered = true;
for (const play of segmentPlayers) play();
if (once) observer.disconnect();
} else if (!once) {
viewState.hasEntered = false;
}
},
{ threshold: 0.1 },
);
observer.observe(element);
node.addHook("Remove", () => observer.disconnect());
},
style: {
display: by === "line" ? "block" : "inline",
...(props.style ?? {}),
} as StyleObject,
} as unknown as DomphyElement;
return outer;
}
export { textAnimate };