sparklesText
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call sparklesText() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full behavior implemented: plain inherited-style text plus a reactive sparkle list seeded on mount and refreshed on an interval (spawnInterval = cycleDuration / sparkleCount), each sparkle a 4-point star SVG positioned at a random top/left percent with its own CSS keyframe animation (scale 0->1 + quarter rotate, hold, scale back to 0, opacity in/out) and self-cleanup via a matched setTimeout so the DOM population stays roughly constant. The upstream demo's two literal hex accent colors (#A07CFE violet, #FE8FB5 pink) cannot be used verbatim (Domphy forbids raw color literals) — mapped to the closest built-in theme families, secondary (rose/pink) and primary (blue, no violet family exists in the default theme), both overridable via the colors prop.
Status: ported · Reference: Magic UI original
// magicui "Sparkles Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). Static
// text overlaid with a handful of small star-shaped sparkles that
// continuously spawn at random positions, twinkle (scale + rotate + fade in
// then out) over a short cycle, and are retired from the DOM once their
// cycle finishes — a JS interval feeding a reactive list, one CSS keyframe
// animation per sparkle. Same general technique as the well-known "animated
// sparkles in React" pattern (random-position spawn + scale/rotate keyframe
// + periodic replace).
//
// The upstream demo's two accent colors are literal hex values; Domphy
// forbids raw color literals, so they are mapped to the closest built-in
// theme color families (`secondary` for the pink/magenta tone, `primary`
// for the cooler second tone) and exposed as an overridable prop.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface SparklesTextProps {
/** Text content. Defaults to a short demo phrase. */
children?: string;
/** Roughly how many sparkles are alive at once. Defaults to 10. */
sparkleCount?: number;
/** The two accent colors sparkles alternate between. Defaults to `["secondary", "primary"]`. */
colors?: [ThemeColor, ThemeColor];
/** Smallest sparkle size, in `themeSpacing` units. Defaults to 1.5. */
minSize?: number;
/** Largest sparkle size, in `themeSpacing` units. Defaults to 3. */
maxSize?: number;
/** Milliseconds for one sparkle's full grow/hold/shrink cycle. Defaults to 900. */
cycleDuration?: number;
/** Passthrough style merged onto the text span. */
style?: StyleObject;
}
interface SparkleEntry {
key: string;
topPercent: number;
leftPercent: number;
sizeUnits: number;
color: ThemeColor;
}
/** Four-pointed star/sparkle glyph, colored via `currentColor` + a themed `style.color`. */
function sparkleGlyph(
color: ThemeColor,
sizeUnits: number,
): DomphyElement<"svg"> {
return {
svg: [
{
path: null,
d: "M12 0C13.3 6.3 14.4 9.7 21 12C14.4 14.3 13.3 17.7 12 24C10.7 17.7 9.6 14.3 3 12C9.6 9.7 10.7 6.3 12 0Z",
},
],
viewBox: "0 0 24 24",
fill: "currentColor",
ariaHidden: "true",
style: {
display: "block",
width: themeSpacing(sizeUnits),
height: themeSpacing(sizeUnits),
color: (listener) => themeColor(listener, "shift-9", color),
} as StyleObject,
} as DomphyElement<"svg">;
}
function sparkleElement(
entry: SparkleEntry,
animationName: string,
cycleDuration: number,
): DomphyElement<"span"> {
return {
span: [sparkleGlyph(entry.color, entry.sizeUnits)],
_key: entry.key,
ariaHidden: "true",
style: {
position: "absolute",
insetBlockStart: `${entry.topPercent}%`,
insetInlineStart: `${entry.leftPercent}%`,
zIndex: 0,
pointerEvents: "none",
animation: `${animationName} ${cycleDuration}ms ease-in-out forwards`,
},
};
}
/**
* Text overlaid with a steady population of small twinkling star sparkles
* that continuously spawn, grow, hold, and fade at random positions. Runs
* automatically — no interaction required. Call with no arguments for a
* working demo phrase.
*/
function sparklesText(props: SparklesTextProps = {}): DomphyElement<"span"> {
const text = props.children ?? "Sparkles Everywhere";
const sparkleCount = Math.max(1, Math.round(props.sparkleCount ?? 10));
const colors =
props.colors ?? (["secondary", "primary"] as [ThemeColor, ThemeColor]);
const minSize = props.minSize ?? 1.5;
const maxSize = props.maxSize ?? 3;
const cycleDuration = props.cycleDuration ?? 900;
const keyframes = {
"0%": { transform: "scale(0) rotate(0deg)", opacity: 0 },
"25%": { transform: "scale(1) rotate(90deg)", opacity: 1 },
"75%": { transform: "scale(1) rotate(90deg)", opacity: 1 },
"100%": { transform: "scale(0) rotate(180deg)", opacity: 0 },
};
const animationName = `sparkles-text-twinkle-${hashString(JSON.stringify(keyframes))}`;
const sparkles = toState<SparkleEntry[]>([]);
return {
span: [
{ span: text, style: { position: "relative", zIndex: 1 } },
{
span: (listener) =>
sparkles
.get(listener)
.map((entry) =>
sparkleElement(entry, animationName, cycleDuration),
),
style: { position: "absolute", inset: 0, pointerEvents: "none" },
},
],
style: {
position: "relative",
display: "inline-block",
[`@keyframes ${animationName}`]: keyframes,
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
let insertCount = 0;
const spawnIntervalMs = Math.max(cycleDuration / sparkleCount, 50);
const pendingRetireTimeouts = new Set<ReturnType<typeof setTimeout>>();
const spawnSparkle = () => {
insertCount += 1;
const key = `sparkle-${insertCount}`;
const entry: SparkleEntry = {
key,
topPercent: Math.round(Math.random() * 100),
leftPercent: Math.round(Math.random() * 100),
sizeUnits: minSize + Math.random() * (maxSize - minSize),
color: insertCount % 2 === 0 ? colors[0] : colors[1],
};
sparkles.set([...sparkles.get(), entry]);
// Self-cleanup once this sparkle's own twinkle cycle has finished
// playing, so the DOM population stays roughly constant.
const retireTimeout = setTimeout(() => {
pendingRetireTimeouts.delete(retireTimeout);
sparkles.set(sparkles.get().filter((item) => item.key !== key));
}, cycleDuration);
pendingRetireTimeouts.add(retireTimeout);
};
spawnSparkle();
const spawnTimer = setInterval(spawnSparkle, spawnIntervalMs);
node.addHook("Remove", () => {
clearInterval(spawnTimer);
for (const retireTimeout of pendingRetireTimeouts)
clearTimeout(retireTimeout);
pendingRetireTimeouts.clear();
});
},
};
}
export { sparklesText };