A very smooth and interactive trail of images that follows your mouse cursor with discrete step interpolation.
@grootstudio
registry/radix-nova/text-outline-glow/text-outline-glow.tsx
import React, { useRef, useState, useEffect } from "react"
import { useGSAP } from "@gsap/react"
import gsap from "gsap"
export const TextHoverEffect = ({
text,
duration,
fontSize = 56,
colors = ["#eab308", "#ef4444", "#3b82f6", "#06b6d4", "#8b5cf6"],
textFill,
}: {
text: string
duration?: number
fontSize?: number
colors?: string[]
textFill?: string
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const maskGradientRef = useRef(null)
const animatedTextRef = useRef(null)
const [cursor, setCursor] = useState({ x: 0, y: 0 })
const [hovered, setHovered] = useState(false)
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" })
useGSAP(
() => {
gsap.fromTo(
animatedTextRef.current,
{ strokeDashoffset: 1000, strokeDasharray: 1000 },
{
strokeDashoffset: 0,
strokeDasharray: 1000,
duration: 4,
ease: "power2.inOut",
}
)
},
{ scope: svgRef }
)
const updateCursorPosition = (x: number, y: number) => {
if (svgRef.current && x !== null && y !== null) {
const svgRect = svgRef.current.getBoundingClientRect()
const cxPercentage = ((x - svgRect.left) / svgRect.width) * 100
const cyPercentage = ((y - svgRect.top) / svgRect.height) * 100
const newPosition = {
cx: `${cxPercentage}%`,
cy: `${cyPercentage}%`,
}
setMaskPosition(newPosition)
gsap.to(maskGradientRef.current, {
attr: newPosition,
duration: duration ?? 0,
ease: "power2.out",
})
}
}
useEffect(() => {
updateCursorPosition(cursor.x, cursor.y)
}, [cursor, duration])
const handleMouseEnter = () => setHovered(true)
const handleMouseLeave = () => setHovered(false)
const handleMouseMove = (e: React.MouseEvent) => {
setCursor({ x: e.clientX, y: e.clientY })
}
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault()
setHovered(true)
if (e.touches.length > 0) {
const touch = e.touches[0]
if (touch) {
setCursor({ x: touch.clientX, y: touch.clientY })
}
}
}
const handleTouchMove = (e: React.TouchEvent) => {
e.preventDefault()
if (e.touches.length > 0) {
const touch = e.touches[0]
if (touch) {
setCursor({ x: touch.clientX, y: touch.clientY })
}
}
}
const handleTouchEnd = (e: React.TouchEvent) => {
e.preventDefault()
setHovered(false)
}
return (
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 300 100"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
className="select-none"
>
<defs>
<linearGradient
id="textGradient"
gradientUnits="userSpaceOnUse"
cx="50%"
cy="50%"
r="20%"
>
{hovered && (
<>
{colors.map((color, idx) => (
<stop
key={idx}
offset={
colors.length === 1
? "0%"
: `${(idx / (colors.length - 1)) * 100}%`
}
stopColor={color}
/>
))}
</>
)}
</linearGradient>
<radialGradient
id="revealMask"
ref={maskGradientRef}
gradientUnits="userSpaceOnUse"
r="25%"
cx={maskPosition.cx}
cy={maskPosition.cy}
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</radialGradient>
<mask id="textMask">
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#revealMask)"
/>
</mask>
</defs>
{[0, 1, 2].map((_, idx) => (
<text
key={idx}
ref={idx === 1 ? animatedTextRef : undefined}
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.8"
className={`${textFill ? '' : 'fill-muted'} font-[helvetica] font-bold ${
idx === 0
? "stroke-neutral-300 dark:stroke-neutral-800"
: idx === 1
? "stroke-neutral-300 dark:stroke-neutral-800"
: ""
}`}
stroke={idx === 2 ? "url(#textGradient)" : undefined}
mask={idx === 2 ? "url(#textMask)" : undefined}
style={{
...(textFill ? { fill: textFill } : {}),
fontSize,
opacity: idx === 0 && !hovered ? 0 : idx === 0 ? 0.7 : 1,
}}
>
{text}
</text>
))}
</svg>
)
}



























