layoutTextFlip
A Text block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call layoutTextFlip() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full behavioral port of the documented props (text/words/duration): a fixed lead-in phrase plus a rotating word in an edge-anchored dataTone badge. Word crossfade reuses this package's existing wordRotate.ts idiom (single-item reactive keyed list + motion() enter/exit slide+fade). The badge's own width smoothly tweens via a SEPARATE motion() instance driven by a reactive State<MotionKeyframe> — because motion() replays a single-keyframe WAAPI animation, the browser implicitly animates FROM the badge's current rendered width, producing the spec's 'eased tween from the old word's width to the new word's' without any FLIP/layout-animation library. Target widths are measured via an offscreen canvas.measureText() call against the badge's own resolved font (read once via getComputedStyle) rather than a DOM measure round-trip, avoiding any mount-order dependency; the very first word's width is set directly (no animation) to avoid a layout jump. FIDELITY GAP (documented in-file and per the task's own researchNote): the upstream docs page only exposed the props table, not the rendered demo's computed badge styling, so the badge's border/background/shadow are this implementation's own reasonable design choice (this package's standard edge-anchored dataTone chip convention), not a confirmed 1 visual match. Doctor-clean (0 diagnostics) and 4/4 tests pass.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Layout Text Flip" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// heading line pairing a fixed static lead-in phrase with a rotating word
// shown inside its own elevated, rounded badge — the badge crossfades the
// outgoing/incoming word (small vertical slide) AND smoothly tweens its own
// width to fit each new word, so the whole line visibly reflows around it
// instead of snapping.
//
// FIDELITY NOTE (per the task's own researchNote): the upstream docs page
// exposed only the props table (text/words/duration), not the rendered
// demo's computed badge styling — the badge's border/shadow/color below are
// this implementation's own reasonable design choice (an edge-anchored
// `dataTone` chip surface, this package's standard convention for a small
// elevated pill — see `iconBadge()` in `shadcn/sidebar/sidebar05-08-shared.ts`),
// not a confirmed 1:1 visual match.
//
// The word crossfade reuses this package's own `wordRotate.ts` idiom: a
// single-item reactive keyed list, replaced wholesale on each tick so the
// reconciler runs the outgoing key's `motion()` exit and the incoming key's
// enter at once. The badge's own width tween is a SEPARATE `motion()`
// instance applied to the badge itself, driven by a reactive `animate`
// `State<MotionKeyframe>` (`{ width: "…px" }`) — `motion()` replays a WAAPI
// animation whenever that state changes, and because only ONE keyframe is
// given, the Web Animations API implicitly starts from the badge's current
// (already-rendered) width — exactly the "eased tween from the old word's
// width to the new word's" the spec calls for. The target width itself is
// measured with an offscreen `canvas.measureText()` call (using the badge's
// own resolved font, read once via `getComputedStyle`) rather than waiting
// on a DOM mount/measure round-trip, so there is no mount-order dependency
// between the badge's own setup and the word layers swapping inside it. The
// very first word's width is written directly (no animation) so there is no
// layout jump before the first swap.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { heading, motion } from "@domphy/ui";
import type { MotionKeyframe } from "@domphy/ui";
import { type ThemeColor, themeColor, themeDensity, themeSpacing } from "@domphy/theme";
export interface LayoutTextFlipProps {
/** Static lead-in phrase shown before the rotating word. Defaults to `"Build with"`. */
text?: string;
/** Words cycled through inside the badge, 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 family for the badge's own elevated surface. Defaults to `"primary"`. */
badgeColor?: ThemeColor;
/** Passthrough style merged onto the outer heading line. */
style?: StyleObject;
/** Passthrough style merged onto the badge wrapper. */
badgeStyle?: StyleObject;
/** Extra class name merged onto the outer heading container. */
className?: string;
}
interface WordEntry {
key: string;
text: string;
}
const HORIZONTAL_PADDING_UNITS = 3;
const SLIDE_DISTANCE_EM = 0.4;
const CROSSFADE_DURATION_MS = 320;
const WIDTH_TWEEN_DURATION_MS = 380;
const EASE_OUT_EXPO = "cubic-bezier(0.16, 1, 0.3, 1)";
const DEFAULT_WORDS = ["curious", "creative", "capable", "reliable"];
function wordLayer(entry: WordEntry): DomphyElement<"span"> {
return {
span: entry.text,
_key: entry.key,
style: {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
whiteSpace: "nowrap",
} as StyleObject,
$: [
motion({
initial: { opacity: 0, y: `${SLIDE_DISTANCE_EM}em` },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: `-${SLIDE_DISTANCE_EM}em` },
transition: { duration: CROSSFADE_DURATION_MS, easing: EASE_OUT_EXPO },
}),
],
};
}
/**
* A heading line pairing a static lead-in phrase with a rotating word shown
* inside its own elevated, rounded badge — the badge crossfades between
* words and smoothly tweens its own width to fit each one, so the line
* visibly reflows instead of snapping. Call with no arguments for a working
* demo.
*/
function layoutTextFlip(props: LayoutTextFlipProps = {}): DomphyElement<"h2"> {
const leadText = props.text ?? "Build with";
const words = props.words && props.words.length > 0 ? props.words : DEFAULT_WORDS;
const holdDurationMs = props.duration ?? 2500;
const badgeColor = props.badgeColor ?? "primary";
const layers = toState<WordEntry[]>([{ key: "word-0", text: words[0] }]);
const badgeWidth = toState<MotionKeyframe>({});
let wordIndex = 0;
let insertCount = 0;
// Populated once the badge mounts and resolves its own font — used both
// for the very first word's initial (unanimated) width and for every
// subsequent swap's target width.
let measureWordWidth: ((word: string) => number) | null = null;
let horizontalPaddingPx = 0;
function targetWidthPx(word: string): number {
const measuredWidth = measureWordWidth ? measureWordWidth(word) : word.length * 10;
return Math.ceil(measuredWidth + horizontalPaddingPx * 2);
}
const advance = () => {
if (words.length <= 1) return;
wordIndex = (wordIndex + 1) % words.length;
insertCount += 1;
const nextWord = words[wordIndex];
layers.set([{ key: `word-${insertCount}`, text: nextWord }]);
badgeWidth.set({ width: `${targetWidthPx(nextWord)}px` });
};
const badgeElement: DomphyElement<"span"> = {
span: [
{
span: (listener: Listener) => layers.get(listener).map(wordLayer),
style: {
position: "relative",
display: "inline-block",
minHeight: "1.15em",
minWidth: "1ch",
} as StyleObject,
},
],
// Edge-anchored dataTone chip surface — this package's standard
// convention for a small elevated pill (adapts light/dark automatically,
// per the doctor's dataTone-surface-contract idiom).
dataTone: "shift-2",
style: {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
verticalAlign: "middle",
borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 4),
paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * HORIZONTAL_PADDING_UNITS),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3", badgeColor)}`,
outlineOffset: "-1px",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", badgeColor),
color: (listener: Listener) => themeColor(listener, "shift-11", badgeColor),
...(props.badgeStyle ?? {}),
} as StyleObject,
$: [motion({ animate: badgeWidth, transition: { duration: WIDTH_TWEEN_DURATION_MS, easing: EASE_OUT_EXPO } })],
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const badgeDomElement = node.domElement as HTMLElement;
const computedStyle = window.getComputedStyle(badgeDomElement);
horizontalPaddingPx = (parseFloat(computedStyle.paddingLeft) || 0) + (parseFloat(computedStyle.paddingRight) || 0);
const measureCanvas = document.createElement("canvas");
const context = measureCanvas.getContext("2d");
if (context) {
context.font = `${computedStyle.fontWeight || "700"} ${computedStyle.fontSize || "16px"} ${computedStyle.fontFamily || "sans-serif"}`;
measureWordWidth = (word: string) => context.measureText(word).width;
}
// Size the first word directly (no animation) so there is no jump
// before the first scheduled swap.
badgeDomElement.style.width = `${targetWidthPx(words[wordIndex])}px`;
},
_onRemove: () => {
measureWordWidth = null;
},
};
return {
h2: [`${leadText} `, badgeElement],
class: props.className,
$: [heading()],
style: {
display: "inline-flex",
alignItems: "center",
flexWrap: "wrap",
gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined" || words.length <= 1) return;
const timer = window.setInterval(advance, holdDurationMs);
node.addHook("Remove", () => window.clearInterval(timer));
},
} as DomphyElement<"h2">;
}
export { layoutTextFlip };