flowingTextMarquee
A Labs block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call flowingTextMarquee() with no arguments for a working demo, or edit the code below live.
Implementation notes
SVG <textPath> along a wavy/looping guide curve, scrolled continuously via a native SMIL <animate> on startOffset (no JS timers for the common case); supports multi-phrase cycling, pause-on-hover (via SVGSVGElement.pauseAnimations()), and an optional visible guide stroke. Curve geometry and scroll speed are original/best-guess, matching the spec's own 'moderate confidence' research note (the live demo could not be observed). While implementing this, found and worked around a real @domphy/core bug: attributeName/repeatCount/startOffset are case-sensitive SVG attributes not on core's CamelAttributes allowlist, so declaring them as normal props silently kebab-cases them into invalid SVG (attribute-name/repeat-count) that a real browser ignores -- fixed locally by setting them imperatively via setAttribute in _onMount, without touching the shared core package. Also added an aria-hidden svg + sr-only text-duplicate split (matching this package's auroraText.ts convention) since curved SVG text is not screen-reader-friendly.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Wispr Flow Text Animation" — clean-room reimplementation
// from the public one-line description ("Text marquee animation achieved
// with SVG and motion") and static preview image only; the interactive demo
// itself could not be loaded during research (see the spec's research note),
// so scroll speed and looping behavior are inferred, not confirmed.
//
// An ambient, auto-scrolling sentence that flows along a wavy/looping SVG
// path via `<textPath>`, rather than a straight marquee line. The scroll
// itself is driven by a native SMIL `<animate>` on `startOffset` — the same
// "declarative, no JS timers" technique shineBorder.ts uses for its rotating
// gradient — so it degrades to static (but still curved) text wherever SMIL
// is unavailable, and needs no per-frame JS at all for the common case.
//
// The sentence and its looping `<animate>` are both wrapped as their own
// keyed elements (`_key: "phrase-text"` / `"scroll-loop"`) inside the
// reactive `textPath` content array, so cycling between multiple phrases
// only replaces the text node's own content and never remounts (and
// restarts) the SMIL loop.
//
// `attributeName`/`repeatCount` are set imperatively in `_onMount` rather
// than as declarative props: Domphy's attribute renderer kebab-cases any
// attribute not on its `CamelAttributes` allowlist, and neither of these two
// SMIL attributes is on it (only `gradientTransform`/`gradientUnits`/etc are)
// — declaratively they'd render as `attribute-name="startOffset"` /
// `repeat-count="indefinite"`, which the SVG spec does not recognize, so the
// animation would silently do nothing in a real browser. `from`/`to`/`dur`
// are unaffected (already all-lowercase, so kebab-casing is a no-op).
//
// The curved `<svg>` copy is `aria-hidden` (a screen reader gets nothing
// useful from text bent along a path), paired with a visually-hidden plain-
// text duplicate of the current phrase — the same sr-only-text +
// aria-hidden-decoration split auroraText.ts uses for its own gradient-clip
// text.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSize } from "@domphy/theme";
export interface FlowingTextMarqueeProps {
/** Sentence(s) to flow along the curve. When more than one is given they cycle automatically. */
phrases?: string[];
/** How long each phrase is shown before cycling to the next, in ms. Only used when `phrases.length > 1`. Defaults to `7000`. */
phraseDurationMs?: number;
/** Raw SVG path data for the guide curve. Defaults to a wide, open wavy loop. */
pathData?: string;
/** Matches `pathData`'s coordinate space. Defaults to `"0 0 1200 480"`. */
viewBox?: string;
/** One full scroll loop, in seconds. Defaults to `26`. */
scrollDurationSeconds?: number;
/** Text color family. Defaults to `"neutral"`. */
color?: ThemeColor;
/** Pauses the scroll while the pointer is over the section. Defaults to `false`. */
pauseOnHover?: boolean;
/** Strokes the guide curve itself instead of hiding it. Defaults to `false`. */
showGuidePath?: boolean;
style?: StyleObject;
}
const DEFAULT_PATH_DATA =
"M 60 380 C 260 60, 620 60, 780 220 C 900 340, 1080 340, 1130 200 C 1170 90, 1080 20, 970 60 C 860 100, 840 220, 950 260";
const DEFAULT_VIEW_BOX = "0 0 1200 480";
const DEFAULT_PHRASE =
"So basically what happened was we kept going back and forth about it for days until someone just decided to ship the thing and see what happens";
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;
let flowingTextMarqueeInstanceCounter = 0;
/**
* An ambient, auto-scrolling sentence that flows continuously along a
* wavy/looping SVG path instead of a straight marquee line. Call with no
* arguments for a working demo — a single generic dictation-style sentence
* streaming through a wide open loop.
*/
function flowingTextMarquee(props: FlowingTextMarqueeProps = {}): DomphyElement<"div"> {
const phrases = props.phrases && props.phrases.length > 0 ? props.phrases : [DEFAULT_PHRASE];
const phraseDurationMs = props.phraseDurationMs ?? 7000;
const pathData = props.pathData ?? DEFAULT_PATH_DATA;
const viewBox = props.viewBox ?? DEFAULT_VIEW_BOX;
const scrollDurationSeconds = props.scrollDurationSeconds ?? 26;
const color = props.color ?? "neutral";
const pauseOnHover = props.pauseOnHover ?? false;
const showGuidePath = props.showGuidePath ?? false;
const instanceId = ++flowingTextMarqueeInstanceCounter;
const pathId = `domphy-flowing-text-marquee-path-${instanceId}`;
const activePhraseIndex = toState(0);
let cycleTimer: ReturnType<typeof setInterval> | null = null;
let svgElement: SVGSVGElement | null = null;
const currentPhraseText = (listener?: Listener) => {
const phrase = phrases[activePhraseIndex.get(listener)];
// Doubled with a spacer so the loop (start -> -100% offset) has no visible seam.
return `${phrase} ${phrase}`;
};
const guidePath: DomphyElement<"path"> = {
path: null,
id: pathId,
d: pathData,
fill: "none",
stroke: showGuidePath ? (listener: Listener) => themeColor(listener, "shift-4", color) : "none",
strokeWidth: "1.5",
};
const scrollLoop: DomphyElement<"animate"> = {
animate: null,
_key: "scroll-loop",
from: "0%",
to: "-50%",
dur: `${scrollDurationSeconds}s`,
_onMount: (node: ElementNode) => {
const element = node.domElement as SVGAnimateElement | null;
element?.setAttribute("attributeName", "startOffset");
element?.setAttribute("repeatCount", "indefinite");
},
} as DomphyElement<"animate">;
const phraseTextSpan: DomphyElement<"tspan"> = {
tspan: phrases.length > 1 ? (listener: Listener) => currentPhraseText(listener) : currentPhraseText(),
_key: "phrase-text",
} as DomphyElement<"tspan">;
return {
div: [
{
span: phrases.length > 1 ? (listener: Listener) => phrases[activePhraseIndex.get(listener)] : phrases[0],
_key: "sr-only-text",
style: SR_ONLY_STYLE,
} as DomphyElement<"span">,
{
svg: [
{ defs: [guidePath] } as DomphyElement,
{
text: [
{
textPath: [phraseTextSpan, scrollLoop],
href: `#${pathId}`,
// `startOffset` is case-sensitive SVG too and not on the allowlist — see the
// file-header note on `scrollLoop` above.
_onMount: (node: ElementNode) => (node.domElement as SVGTextPathElement | null)?.setAttribute("startOffset", "0%"),
} as DomphyElement,
],
// SVG `<text>` has no matching Domphy typography patch (heading()/paragraph()
// assert their host is an h*/p tag) — theme-token *functions* below are the
// doctor-compliant equivalent for SVG text; only literal values trip inline-typography.
style: {
fontSize: (listener: Listener) => themeSize(listener, "increase-2"),
color: (listener: Listener) => themeColor(listener, "shift-7", color),
fill: (listener: Listener) => themeColor(listener, "shift-7", color),
} as StyleObject,
} as DomphyElement<"text">,
],
viewBox,
xmlns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
_onMount: (node: ElementNode) => {
svgElement = node.domElement as SVGSVGElement | null;
},
_onRemove: () => {
svgElement = null;
},
style: { display: "block", width: "100%", height: "auto" },
} as DomphyElement<"svg">,
],
onMouseEnter: () => {
if (pauseOnHover) svgElement?.pauseAnimations?.();
},
onMouseLeave: () => {
if (pauseOnHover) svgElement?.unpauseAnimations?.();
},
_onMount: () => {
if (phrases.length <= 1) return;
cycleTimer = setInterval(() => {
activePhraseIndex.set((activePhraseIndex.get() + 1) % phrases.length);
}, phraseDurationMs);
},
_onRemove: () => {
if (cycleTimer) clearInterval(cycleTimer);
},
style: {
position: "relative",
width: "100%",
overflow: "hidden",
...(props.style ?? {}),
} as StyleObject,
};
}
export { flowingTextMarquee };