# Text Outline Glow

> A very smooth and interactive trail of images that follows your mouse cursor with discrete step interpolation.

A shadcn/ui block from the [@grootstudio](https://grootstudio.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/grootstudio/text-outline-glow).

- Page, with a live preview: https://shadcn.exchange/r/grootstudio/text-outline-glow
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/grootstudio/text-outline-glow-light-fc15ea13eecd04c9.png
- Registry item: https://grootstudio.vercel.app/r/text-outline-glow.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://grootstudio.vercel.app/r/text-outline-glow.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Files

### `registry/radix-nova/text-outline-glow/text-outline-glow.tsx`

```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>
  )
}
```
