Text Spotlight

PreviousNext

spotlight text effect with hover and mobile reveal, customizable size and color.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/text-spotlight.tsx
"use client";

import React, { useRef, useState, useEffect } from "react";
import { motion, useMotionValue, useMotionTemplate } from "framer-motion";
import { cn } from "@/lib/utils";

interface TextSpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
  text: string;
  spotlightColor?: string;
  spotlightSize?: number;
  textClassName?: string;
  spotlightOpacity?: number;
  spotlightArea?: number;
  animateOnPhone?: boolean;
  colorDuration?: number;
}

const BREAKPOINT = 1024;

const useIsMobile = () => {
  const [isMobile, setIsMobile] = useState(false);
  
  useEffect(() => {
    const handleResize = () => setIsMobile(window.innerWidth < BREAKPOINT);
    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);
  
  return isMobile;
};

export function TextSpotlight({
  text,
  className,
  textClassName,
  spotlightColor = "255, 255, 255",
  spotlightSize = 450,
  spotlightOpacity = 1,
  spotlightArea,
  animateOnPhone = false,
  colorDuration = 2000,
  ...props
}: TextSpotlightProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mouseX = useMotionValue(0);
  const mouseY = useMotionValue(0);
  const [isHovered, setIsHovered] = useState(false);
  const [isVisible, setIsVisible] = useState(false);
  const [revealProgress, setRevealProgress] = useState(0);
  const isMobile = useIsMobile();

  useEffect(() => {
    if (!animateOnPhone || !isMobile || !containerRef.current) return;
    
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsVisible(entry.isIntersecting);
        if (!entry.isIntersecting) {
          setRevealProgress(0);
        }
      },
      { threshold: 0.3 }
    );
    
    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, [animateOnPhone, isMobile]);

  useEffect(() => {
    if (!animateOnPhone || !isMobile || !isVisible) return;
    
    const startTime = Date.now();
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / colorDuration, 1);
      setRevealProgress(progress);
      
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    
    requestAnimationFrame(animate);
  }, [isVisible, animateOnPhone, isMobile, colorDuration]);

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!containerRef.current) return;
    const rect = containerRef.current.getBoundingClientRect();
    mouseX.set(e.clientX - rect.left);
    mouseY.set(e.clientY - rect.top);
    setIsHovered(true);
  };

  const handleMouseLeave = () => {
    setIsHovered(false);
  };

  const background = useMotionTemplate`
    radial-gradient(
      ${spotlightSize}px circle at ${mouseX}px ${mouseY}px,
      rgba(${spotlightColor}, ${spotlightOpacity}),
      transparent 80%
    )
  `;

  const chars = text.split('');
  const shouldShowMobileReveal = animateOnPhone && isMobile;

  return (
    <div
      ref={containerRef}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      className={cn(
        "relative w-full overflow-hidden",
        className
      )}
      {...props}
    >
      {shouldShowMobileReveal ? (
        <p className={cn(
          "relative z-10 select-none text-center",
          textClassName
        )}>
          {chars.map((char, index) => {
            const charProgress = (revealProgress * chars.length - index) / 1;
            const opacity = Math.max(0, Math.min(1, charProgress));
            const isRevealed = opacity > 0.5;
            
            return (
              <span
                key={index}
                style={{
                  transition: 'all 0.3s ease-out',
                  color: isRevealed ? 'inherit' : undefined,
                }}
                className={isRevealed ? '' : 'text-gray-600/10 dark:text-white/15'}
              >
                {char}
              </span>
            );
          })}
        </p>
      ) : (
        <>
          <p className={cn(
            "relative z-10 text-gray-600/10 dark:text-white/15 select-none text-center",
            textClassName
          )}>
            {text}
          </p>

          <motion.div
            className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none"
            style={{
              WebkitMaskImage: background,
              maskImage: background,
              opacity: isHovered ? 1 : 0,
            }}
          >
            <p className={cn(
              "text-foreground text-center",
              textClassName
            )}>
              {text}
            </p>
          </motion.div>
        </>
      )}
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/text-spotlight

Usage

import { TextSpotlight } from "@/components/text-spotlight"
<TextSpotlight />