A spotlight component from Motion Primitives.
1'use client';
2import React, { useRef, useState, useCallback, useEffect } from 'react';
3import { motion, useSpring, useTransform, SpringOptions } from 'motion/react';
4import { cn } from '@/lib/utils';
5
6export type SpotlightProps = {
7 className?: string;
8 size?: number;
9 springOptions?: SpringOptions;
10};
11
12export function Spotlight({
13 className,
14 size = 200,
15 springOptions = { bounce: 0 },
16}: SpotlightProps) {
17 const containerRef = useRef<HTMLDivElement>(null);
18 const [isHovered, setIsHovered] = useState(false);
19 const [parentElement, setParentElement] = useState<HTMLElement | null>(null);
20
21 const mouseX = useSpring(0, springOptions);
22 const mouseY = useSpring(0, springOptions);
23
24 const spotlightLeft = useTransform(mouseX, (x) => `${x - size / 2}px`);
25 const spotlightTop = useTransform(mouseY, (y) => `${y - size / 2}px`);
26
27 useEffect(() => {
28 if (containerRef.current) {
29 const parent = containerRef.current.parentElement;
30 if (parent) {
31 parent.style.position = 'relative';
32 parent.style.overflow = 'hidden';
33 setParentElement(parent);
34 }
35 }
36 }, []);
37
38 const handleMouseMove = useCallback(
39 (event: MouseEvent) => {
40 if (!parentElement) return;
41 const { left, top } = parentElement.getBoundingClientRect();
42 mouseX.set(event.clientX - left);
43 mouseY.set(event.clientY - top);
44 },
45 [mouseX, mouseY, parentElement]
46 );
47
48 useEffect(() => {
49 if (!parentElement) return;
50
51 const abortController = new AbortController();
52
53 parentElement.addEventListener('mousemove', handleMouseMove, {
54 signal: abortController.signal,
55 });
56 parentElement.addEventListener('mouseenter', () => setIsHovered(true), {
57 signal: abortController.signal,
58 });
59 parentElement.addEventListener('mouseleave', () => setIsHovered(false), {
60 signal: abortController.signal,
61 });
62
63 return () => {
64 abortController.abort();
65 };
66 }, [parentElement, handleMouseMove]);
67
68 return (
69 <motion.div
70 ref={containerRef}
71 className={cn(
72 'pointer-events-none absolute rounded-full bg-[radial-gradient(circle_at_center,var(--tw-gradient-stops),transparent_80%)] blur-xl transition-opacity duration-200',
73 'from-zinc-100 via-zinc-200 to-zinc-400 dark:from-zinc-50 dark:via-zinc-100 dark:to-zinc-200',
74 isHovered ? 'opacity-100' : 'opacity-0',
75 className
76 )}
77 style={{
78 width: size,
79 height: size,
80 left: spotlightLeft,
81 top: spotlightTop,
82 }}
83 />
84 );
85}
86