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.
Props
| Prop | Type | Description |
|---|---|---|
text | ValueOrState<string> | Text content. Pass a State<string> for automatic replay when it changes. Defaults to a short demo sentence. |
by | TextAnimateBy | How the text is split into animated segments. Defaults to "word". |
animation | TextAnimatePreset | Which hidden→visible keyframe pair each segment animates through. Defaults to "fadeIn". |
duration | number | Total time window the whole staggered reveal is spread across, in ms (matching upstream's duration): the inter-segment delay is duration / segments.length. Each segment's own tween is a fixed 300ms. Defaults to 300. |
delay | number | Delay before the first segment starts, in ms. Defaults to 0. |
startOnView | boolean | Waits until the wrapper scrolls into view before starting. Defaults to true. |
once | boolean | Once triggered by scrolling into view, never re-triggers on re-entry. Only relevant when startOnView is true. Defaults to false. |
as | TextAnimateTag | Wrapping element tag. Defaults to "p". |
accessibility | boolean | Keeps the full, unsplit text readable to screen readers via a visually-hidden duplicate plus an aria-label on the wrapper, marking the animated segments aria-hidden. Defaults to true. |
initialStyle | Record<string, string | number> | Overrides merged onto the computed hidden keyframe. |
animateStyle | Record<string, string | number> | Overrides merged onto the computed visible (resting) keyframe. |
exitStyle | Record<string, string | number> | Overrides merged onto the exit keyframe (defaults to the preset's own exit keyframe if it has one, otherwise the hidden keyframe). |
segmentStyle | StyleObject | Passthrough style merged onto every segment. |
style | StyleObject | Passthrough style merged onto the outer wrapper. |
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.
//
// Stagger model matches upstream: the whole reveal always spans the same time
// window (`duration`, default 300ms) regardless of how many segments there
// are, so the inter-segment delay is `duration / segments.length` — long
// strings reveal just as fast as short ones. Each segment's own tween is a
// fixed 300ms (upstream hard-codes `duration: 0.3` in every variant and uses
// the `duration` prop only to size the stagger window).
//
// 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";
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"
| "article"
| "section"
| "li"
| "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;
/** Total time window the whole staggered reveal is spread across, in ms
* (matching upstream's `duration`): the inter-segment delay is
* `duration / segments.length`. Each segment's own tween is a fixed 300ms.
* Defaults to `300`. */
duration?: number;
/** Delay before the first segment starts, in ms. Defaults to `0`. */
delay?: number;
/** Waits until the wrapper scrolls into view before starting. Defaults to `true`. */
startOnView?: boolean;
/** Once triggered by scrolling into view, never re-triggers on re-entry. Only
* relevant when `startOnView` is `true`. Defaults to `false`. */
once?: boolean;
/** Wrapping element tag. Defaults to `"p"`. */
as?: TextAnimateTag;
/** Keeps the full, unsplit text readable to screen readers via a visually-hidden
* duplicate plus an `aria-label` on the wrapper, 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 preset's own exit
* keyframe if it has one, otherwise 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;
}
// Every segment animates in upstream — including word-mode whitespace runs,
// which are split out by `children.split(/(\s+)/)` and rendered as their own
// `motion.span` (so they count toward `segments.length` and the stagger).
const SEGMENT_TWEEN_MS = 300;
// Upstream item tweens set only `duration` and rely on framer-motion's default
// tween easing (easeInOut), identical for enter and exit. scaleUp/scaleDown
// give the scale a spring (`type: "spring", damping: 15, stiffness: 300`);
// WAAPI has no spring, so approximate its slight overshoot with a back-out
// cubic-bezier on those two presets' enter tween (exit stays a plain tween,
// matching upstream's spring-free exit variant).
const DEFAULT_EASING = "ease-in-out";
const SPRING_EASING = "cubic-bezier(0.34, 1.56, 0.64, 1)";
const PRESET_KEYFRAMES: Record<
TextAnimatePreset,
{
hidden: Record<string, string | number>;
visible: Record<string, string | number>;
exit?: Record<string, string | number>;
}
> = {
fadeIn: {
hidden: { opacity: 0, transform: "translateY(20px)" },
visible: { opacity: 1, transform: "translateY(0)" },
},
blurIn: {
hidden: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "blur(0)" },
},
blurInUp: {
hidden: { opacity: 0, filter: "blur(10px)", transform: "translateY(20px)" },
visible: { opacity: 1, filter: "blur(0)", transform: "translateY(0)" },
},
blurInDown: {
hidden: {
opacity: 0,
filter: "blur(10px)",
transform: "translateY(-20px)",
},
visible: { opacity: 1, filter: "blur(0)", transform: "translateY(0)" },
},
slideUp: {
hidden: { opacity: 0, transform: "translateY(20px)" },
visible: { opacity: 1, transform: "translateY(0)" },
exit: { opacity: 0, transform: "translateY(-20px)" },
},
slideDown: {
hidden: { opacity: 0, transform: "translateY(-20px)" },
visible: { opacity: 1, transform: "translateY(0)" },
exit: { opacity: 0, transform: "translateY(20px)" },
},
slideLeft: {
hidden: { opacity: 0, transform: "translateX(20px)" },
visible: { opacity: 1, transform: "translateX(0)" },
exit: { opacity: 0, transform: "translateX(-20px)" },
},
slideRight: {
hidden: { opacity: 0, transform: "translateX(-20px)" },
visible: { opacity: 1, transform: "translateX(0)" },
exit: { opacity: 0, transform: "translateX(20px)" },
},
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): string[] {
if (by === "text") return [text];
if (by === "line") return text.split("\n");
if (by === "character") return toGraphemes(text);
// "word": upstream splits with `children.split(/(\s+)/)`, keeping each
// whitespace run as its own segment so spacing survives AND the run counts
// toward `segments.length` (and thus the stagger window).
return text.split(/(\s+)/);
}
/**
* 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. Waits
* until scrolled into view by default (`startOnView`), replaying on every
* re-entry unless `once` 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";
// The `duration` prop sizes the stagger window (upstream `duration`), not the
// per-segment tween — that is a fixed SEGMENT_TWEEN_MS, as upstream hard-codes.
const staggerWindowMs = props.duration ?? 300;
const startDelayMs = props.delay ?? 0;
const startOnView = props.startOnView ?? true;
const once = props.once ?? false;
const wrapperTag = props.as ?? "p";
const accessibility = props.accessibility ?? true;
const preset = PRESET_KEYFRAMES[animationPreset] ?? PRESET_KEYFRAMES.fadeIn;
const isSpringScale =
animationPreset === "scaleUp" || animationPreset === "scaleDown";
const enterEasing = isSpringScale ? SPRING_EASING : DEFAULT_EASING;
const hiddenKeyframe = { ...preset.hidden, ...(props.initialStyle ?? {}) };
const visibleKeyframe = { ...preset.visible, ...(props.animateStyle ?? {}) };
const presetExitKeyframe = preset.exit ?? preset.hidden;
const exitKeyframe = props.exitStyle
? { ...presetExitKeyframe, ...props.exitStyle }
: presetExitKeyframe;
// 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: string,
index: number,
currentText: string,
count: number,
staggerMs: number,
): DomphyElement<"span"> {
const key = `${currentText}::${index}`;
return {
span: segment,
_key: key,
...(accessibility ? { ariaHidden: "true" } : {}),
style: {
display: by === "line" ? "block" : "inline-block",
// Upstream non-line segments carry `whitespace-pre`, so whitespace-run
// segments keep their spaces instead of collapsing under inline-block.
...(by === "line" ? {} : { whiteSpace: "pre" }),
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 + index * staggerMs;
const play = () => {
if (typeof element.animate !== "function") return;
element.animate(
[hiddenKeyframe as Keyframe, visibleKeyframe as Keyframe],
{
duration: SEGMENT_TWEEN_MS,
delay: enterDelayMs,
easing: enterEasing,
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 (upstream `staggerDirection: -1`, exit `delayChildren`
// is 0): the last segment to enter is the first to leave.
const exitDelayMs = (count - 1 - index) * staggerMs;
const animation = element.animate(
[visibleKeyframe as Keyframe, exitKeyframe as Keyframe],
{
duration: SEGMENT_TWEEN_MS,
delay: exitDelayMs,
easing: DEFAULT_EASING,
fill: "both",
},
);
animation.finished.then(
() => done(),
() => done(),
);
},
};
}
function buildSegments(currentText: string): DomphyElement[] {
const segments = splitIntoSegments(currentText, by);
// Whole reveal spans `staggerWindowMs`, independent of segment count.
const staggerMs =
segments.length > 0 ? staggerWindowMs / segments.length : 0;
return segments.map((segment, index) =>
buildSegmentElement(
segment,
index,
currentText,
segments.length,
staggerMs,
),
);
}
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 },
...segmentElements,
];
},
...(accessibility
? { ariaLabel: (listener: Listener) => textState.get(listener) }
: {}),
_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());
},
// Upstream wrapper is a bare motion element with only `whitespace-pre-wrap`
// (plus optional className) — no forced display, no typography.
style: {
whiteSpace: "pre-wrap",
...(props.style ?? {}),
} as StyleObject,
} as unknown as DomphyElement;
return outer;
}
export { textAnimate };