textHoverEffect
A Text block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call textHoverEffect() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full literal port of the domSketch: a viewBox-sized, responsively-scaled SVG with two stacked <text> copies at the same position — a permanent outline-only stencil copy (fill=none, subtle themed stroke) and a gradient-filled copy visible only through an SVG <mask> whose circle tracks the pointer (feGaussianBlur'd for a soft edge). Pointer position is written imperatively to the mask circle's cx/cy on every pointermove (zero-lag, matching this package's own svgMaskEffect.ts tradeoff), with the position/opacity change eased via a plain CSS transition sized by the duration prop (0 = instant snap, matching the documented default). Text fontSize is sized proportionally to string length (no DOM measurement pass needed), keeping the resting render fully SSR-safe. FOUND AND FIXED A REAL BUG during implementation: initially queried the mask circle by ID from the outer container's own _onMount, which failed because a parent's _onMount fires before its subtree has finished attaching (confirmed by instrumented debug run) — refactored to the ref-capture pattern (circle captures its own DOM ref in its own _onMount; the outer pointermove handler reads that ref lazily at event time), matching the idiom this package's backgroundBeams.ts/kineticText.ts already use. Doctor-clean (0 diagnostics) and 3/3 tests pass, including a pointermove/pointerleave interaction test that asserts the reveal circle's opacity actually flips.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Text Hover Effect" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied).
// Very large outlined heading text that fills in with a vivid multi-color
// gradient only in the area the cursor is hovering over, like a colorful
// spotlight revealing paint beneath a stencil.
//
// Two stacked SVG `<text>` copies at the same position: a permanently
// visible thin outline-only copy (`fill="none"`, subtle `stroke`), and a
// gradient-filled copy that is only visible through an SVG `<mask>` whose
// visible region is a soft-edged (`feGaussianBlur`'d) circle tracking the
// pointer. Pointer position is written straight to the mask circle's
// `cx`/`cy` attributes on every `pointermove` — an imperative DOM write, not
// reactive state, matching the zero-lag tradeoff this package's own
// `svgMaskEffect.ts`/`magicCard.ts` make for their own cursor-following
// reveals — with the position/opacity change eased by a plain CSS
// `transition` on the circle sized by the `duration` prop (`0` = snap
// instantly to the pointer, per the documented default).
//
// The SVG is `viewBox`-sized to a fixed internal coordinate space and scaled
// responsively via `width: 100%; height: auto`, so the rendered aspect ratio
// always matches the viewBox exactly — no letterboxing, which keeps the
// pointer's client-to-viewBox coordinate mapping a simple uniform scale. The
// text's own `fontSize` (an SVG attribute, not a CSS `style` prop — the only
// heading-scale mechanism available on `<text>`, which has no HTML heading
// tag to route through `heading()`) is sized proportionally to the string
// length so the glyphs read as "spans most of the container's width"
// regardless of what `text` is passed, without any DOM measurement pass —
// this also makes the resting (non-hovered) render fully SSR-safe, since
// only the pointer-follow behavior needs `_onMount`.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor } from "@domphy/theme";
export interface TextHoverEffectProps {
/** The string rendered as the big outlined heading. Defaults to `"Domphy"`. */
text?: string;
/** Seconds controlling how much the reveal's position/opacity transition is
* eased — `0` snaps instantly to the pointer, larger values add a smooth
* lag/fade. Defaults to `0`. */
duration?: number;
/** Gradient color stops, warm-to-cool, used to fill the pointer-revealed text. Defaults to `["danger", "warning", "info", "secondary"]`. */
colors?: ThemeColor[];
/** Theme color family for the resting outline stroke. Defaults to `"neutral"`. */
strokeColor?: ThemeColor;
/** Extra class name merged onto the outer container's native `class` attribute. */
className?: string;
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
let textHoverEffectInstanceCounter = 0;
const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 300;
// Approximate average glyph-width-to-fontSize ratio for a bold, mostly-
// uppercase display face — used to size the text to fill most of the
// viewBox width without a DOM measurement pass (see file header comment).
const AVERAGE_GLYPH_WIDTH_RATIO = 0.58;
const MIN_FONT_SIZE = 40;
const MAX_FONT_SIZE = 260;
const REVEAL_RADIUS = VIEWBOX_HEIGHT * 0.55;
const REVEAL_BLUR_STD_DEVIATION = VIEWBOX_HEIGHT * 0.05;
const DEFAULT_COLORS: ThemeColor[] = ["danger", "warning", "info", "secondary"];
/** `<stop>` is a paint-server node, not text — it has no visible `color` to
* follow the tone context, so the `missing-color` doctor rule is a false
* positive here (mirrors backgroundBeams.ts's own bandStop() suppression). */
function gradientStop(offset: string, color: ThemeColor): DomphyElement {
return {
stop: null,
offset,
style: { stopColor: (listener) => themeColor(listener, "shift-9", color) } as StyleObject,
_doctorDisable: "missing-color",
} as DomphyElement;
}
/**
* Very large outlined heading text that fills in with a vivid multi-color
* gradient only in the area the cursor hovers over — a colorful spotlight
* revealing paint beneath a stencil. Purely pointer-driven; resting state is
* outline-only. Call with no arguments for a working demo.
*/
function textHoverEffect(props: TextHoverEffectProps = {}): DomphyElement<"div"> {
const text = props.text ?? "Domphy";
const durationSeconds = Math.max(0, props.duration ?? 0);
const colors = props.colors && props.colors.length > 0 ? props.colors : DEFAULT_COLORS;
const strokeColor = props.strokeColor ?? "neutral";
const instanceId = ++textHoverEffectInstanceCounter;
const gradientId = `domphy-text-hover-effect-gradient-${instanceId}`;
const maskId = `domphy-text-hover-effect-mask-${instanceId}`;
const blurFilterId = `domphy-text-hover-effect-blur-${instanceId}`;
const characterCount = Math.max(1, Array.from(text).length);
const fontSize = Math.min(
MAX_FONT_SIZE,
Math.max(MIN_FONT_SIZE, (VIEWBOX_WIDTH * 0.92) / (characterCount * AVERAGE_GLYPH_WIDTH_RATIO)),
);
const gradientStops = colors.map((color, index) =>
gradientStop(`${Math.round((index / Math.max(1, colors.length - 1)) * 100)}%`, color),
);
const revealTransition = durationSeconds > 0 ? `cx ${durationSeconds}s ease-out, cy ${durationSeconds}s ease-out, opacity ${durationSeconds}s ease-out` : "none";
// Captured on the circle's OWN mount rather than queried from the outer
// container's `_onMount` — a parent's `_onMount` fires before its own
// subtree is done attaching, so a `querySelector` for a deeply-nested
// descendant run there would miss it. Event listeners below only read this
// ref at event time (after the whole tree has settled), so it's safe
// regardless of mount order — same idiom this package's own
// `backgroundBeams.ts` (gradient ref) and `kineticText.ts` (character
// refs) already use.
let circleRef: SVGCircleElement | null = null;
const revealCircle: DomphyElement = {
circle: null,
cx: VIEWBOX_WIDTH / 2,
cy: VIEWBOX_HEIGHT / 2,
r: REVEAL_RADIUS,
fill: "white",
// Decorative mask-only shape — no visible color of its own (its fill is
// pure luminance data for the mask, not paint), same reasoning as
// gradientStop()'s own suppression above.
_doctorDisable: "missing-color",
style: { opacity: 0, filter: `url(#${blurFilterId})`, transition: revealTransition } as StyleObject,
_onMount: (node: ElementNode) => {
circleRef = node.domElement as unknown as SVGCircleElement;
},
_onRemove: () => {
circleRef = null;
},
} as DomphyElement;
const outlineText: DomphyElement = {
text,
x: "50%",
y: "50%",
textAnchor: "middle",
dominantBaseline: "middle",
fontSize,
fill: "none",
stroke: (listener: Listener) => themeColor(listener, "shift-6", strokeColor),
strokeWidth: Math.max(1, fontSize * 0.012),
style: { fontWeight: () => "800" } as StyleObject,
} as DomphyElement;
const gradientText: DomphyElement = {
text,
x: "50%",
y: "50%",
textAnchor: "middle",
dominantBaseline: "middle",
fontSize,
fill: `url(#${gradientId})`,
mask: `url(#${maskId})`,
style: { fontWeight: () => "800" } as StyleObject,
} as DomphyElement;
const svgElement: DomphyElement<"svg"> = {
svg: [
{
defs: [
{ linearGradient: gradientStops, id: gradientId, x1: "0%", y1: "0%", x2: "100%", y2: "0%" } as DomphyElement,
{ filter: [{ feGaussianBlur: null, stdDeviation: String(REVEAL_BLUR_STD_DEVIATION) } as DomphyElement], id: blurFilterId } as DomphyElement,
{ mask: [revealCircle], id: maskId } as DomphyElement,
],
} as DomphyElement,
outlineText,
gradientText,
],
viewBox: `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`,
ariaHidden: "true",
style: { width: "100%", height: "auto", display: "block" } as StyleObject,
} as DomphyElement<"svg">;
return {
div: [svgElement],
class: props.className,
role: "img",
ariaLabel: text,
style: {
position: "relative",
width: "100%",
cursor: "default",
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const containerElement = node.domElement as HTMLElement;
const setRevealPosition = (clientX: number, clientY: number) => {
if (!circleRef) return;
const rect = containerElement.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const scale = VIEWBOX_WIDTH / rect.width;
const localX = (clientX - rect.left) * scale;
const localY = (clientY - rect.top) * scale;
circleRef.setAttribute("cx", localX.toFixed(1));
circleRef.setAttribute("cy", localY.toFixed(1));
};
const handlePointerMove = (event: PointerEvent) => {
setRevealPosition(event.clientX, event.clientY);
if (circleRef) circleRef.style.opacity = "1";
};
const handlePointerLeave = () => {
if (circleRef) circleRef.style.opacity = "0";
};
containerElement.addEventListener("pointermove", handlePointerMove);
containerElement.addEventListener("pointerleave", handlePointerLeave);
node.addHook("Remove", () => {
containerElement.removeEventListener("pointermove", handlePointerMove);
containerElement.removeEventListener("pointerleave", handlePointerLeave);
});
},
};
}
export { textHoverEffect };