wordRotate
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call wordRotate() with no arguments for a working demo, or edit the code below live.
Implementation notes
Single-item reactive keyed-array swap on a setInterval timer (reusing the same enter/exit crossfade state-machine pattern this file's morphingText.ts already established), driving motion()'s WAAPI initial/animate/exit for a vertical slide+fade rather than morphingText's goo-filter crossfade. Large/bold styling and 'reverses to white in dark mode' both come for free from themeSize(increase-4)/themeColor(shift-11) — no extra dark-mode code needed. transition{duration,easing} is the requested 'escape hatch' for the crossfade's own timing.
Status: ported · Reference: Magic UI original
// magicui "Word Rotate" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A single
// line of large, bold text that automatically cycles through a fixed list
// of words on a timer: the current word slides up and fades out while the
// next word slides in from below and fades in, at the same position, so
// surrounding layout never jumps. Fully automatic and looping — no
// interaction required.
//
// Structurally this is the same "single-item reactive keyed list" state
// machine `morphingText` uses in this file (replacing the one-item array on
// each tick lets the reconciler run the outgoing word's exit and the
// incoming word's enter at once), swapped from a gooey blur crossfade to a
// plain vertical slide-and-fade via `motion()`'s `initial`/`animate`/`exit`
// keyframes — an odometer/ticker-style word swap rather than a liquid morph.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { motion } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSize } from "@domphy/theme";
export interface WordRotateTransition {
/** Milliseconds the slide/fade crossfade itself takes. Defaults to `350`. */
duration?: number;
/** CSS easing for the crossfade. Defaults to a spring-like ease-out curve. */
easing?: string;
}
export interface WordRotateProps {
/** Words/phrases cycled through in order, looping back to the first. Defaults to a short demo list. */
words?: string[];
/** Milliseconds each word stays visible before switching to the next. Defaults to `2500`. */
duration?: number;
/** Theme color for the word text. Defaults to `"neutral"` (theme foreground, flips light/dark automatically). */
color?: ThemeColor;
/** Escape hatch for the enter/exit crossfade's own timing/easing. See {@link WordRotateTransition}. */
transition?: WordRotateTransition;
/** Passthrough style merged onto the outer fixed-line container. */
style?: StyleObject;
}
interface WordEntry {
key: string;
text: string;
}
const DEFAULT_WORDS = ["better", "faster", "modern", "reactive"];
// Vertical travel distance for the enter/exit slide, in em — scales with the
// word's own font size rather than a fixed pixel offset.
const SLIDE_DISTANCE_EM = 0.5;
function wordLayer(
entry: WordEntry,
color: ThemeColor,
transitionDurationMs: number,
easing: string,
): DomphyElement<"span"> {
return {
span: entry.text,
_key: entry.key,
style: {
position: "absolute",
insetInlineStart: 0,
insetBlockStart: 0,
whiteSpace: "nowrap",
fontSize: (listener: Listener) => themeSize(listener, "increase-4"),
fontWeight: () => "800",
color: (listener: Listener) => themeColor(listener, "shift-11", color),
},
$: [
motion({
initial: { opacity: 0, y: `${SLIDE_DISTANCE_EM}em` },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: `-${SLIDE_DISTANCE_EM}em` },
transition: { duration: transitionDurationMs, easing },
}),
],
};
}
/**
* A single line of large, bold text that automatically and endlessly cycles
* through a word list, sliding/fading the outgoing word out and the
* incoming word in at the same fixed position. No interaction required.
* Call with no arguments for a working demo cycling through a short word list.
*/
function wordRotate(props: WordRotateProps = {}): DomphyElement<"span"> {
const words = props.words && props.words.length > 0 ? props.words : DEFAULT_WORDS;
const holdDuration = props.duration ?? 2500;
const color = props.color ?? "neutral";
const transitionDurationMs = props.transition?.duration ?? 350;
const easing = props.transition?.easing ?? "cubic-bezier(0.16, 1, 0.3, 1)";
const layers = toState<WordEntry[]>([{ key: "word-0", text: words[0] }]);
let wordIndex = 0;
let insertCount = 0;
const advance = () => {
if (words.length <= 1) return;
wordIndex = (wordIndex + 1) % words.length;
insertCount += 1;
layers.set([{ key: `word-${insertCount}`, text: words[wordIndex] }]);
};
return {
span: [
{
span: (listener: Listener) =>
layers.get(listener).map((entry) => wordLayer(entry, color, transitionDurationMs, easing)),
style: { position: "relative", display: "inline-block" },
},
],
style: {
position: "relative",
display: "inline-block",
minHeight: "1.2em",
minWidth: "1ch",
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined" || words.length <= 1) return;
const timer = window.setInterval(advance, holdDuration);
node.addHook("Remove", () => window.clearInterval(timer));
},
} as DomphyElement<"span">;
}
export { wordRotate };