Skip to main content

Spotlight Follow

A cursor-following spotlight in difference blend: drop it inside any relatively-positioned section and a soft circle chases the pointer, inverting whatever it passes over. It binds its listeners to the parent element, so the host section needs no wiring beyond position: relative.

Micro InteractionReactMotionTailwind CSS
CSSTailwind

Manual

Create a file and paste the following code into it.

spotlight-follow.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"use client";

import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  SpotlightFollow                                                    */
/* ------------------------------------------------------------------ */

interface SpotlightFollowProps {
  className?: string;
  /** Classes for the circle itself (color/opacity) [Optional] */
  circleClassName?: string;
  /** Circle diameter in px [Optional, default: 400] */
  circleSize?: number;
}

/**
 * A cursor-following spotlight in difference blend: drop it inside any
 * relatively-positioned section and a soft circle chases the pointer,
 * inverting whatever it passes over. It binds its listeners to the PARENT
 * element, so the host section needs no wiring — just position: relative.
 * @param {number} circleSize - Spotlight diameter [Optional, default: 400]
 */
export function SpotlightFollow({
  className,
  circleClassName = "bg-white/20",
  circleSize = 400,
}: SpotlightFollowProps) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const [isHovered, setIsHovered] = useState(false);
  const [position, setPosition] = useState({ x: "50%", y: "50%" });

  useEffect(() => {
    const parent = hostRef.current?.parentElement;
    if (!parent) return;

    const track = (e: MouseEvent) => {
      const rect = parent.getBoundingClientRect();
      setPosition({
        x: `${((e.clientX - rect.left) / rect.width) * 100}%`,
        y: `${((e.clientY - rect.top) / rect.height) * 100}%`,
      });
    };
    const enter = (e: MouseEvent) => {
      track(e);
      setIsHovered(true);
    };
    const leave = () => setIsHovered(false);

    parent.addEventListener("mouseenter", enter);
    parent.addEventListener("mousemove", track);
    parent.addEventListener("mouseleave", leave);
    return () => {
      parent.removeEventListener("mouseenter", enter);
      parent.removeEventListener("mousemove", track);
      parent.removeEventListener("mouseleave", leave);
    };
  }, []);

  return (
    <div
      ref={hostRef}
      className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}
      aria-hidden
    >
      <motion.div
        className={cn("pointer-events-none absolute rounded-full", circleClassName)}
        style={{
          left: position.x,
          top: position.y,
          width: circleSize,
          height: circleSize,
          mixBlendMode: "difference",
        }}
        initial={{ scale: 0, x: "-50%", y: "-50%" }}
        animate={{ scale: isHovered ? 1 : 0, x: "-50%", y: "-50%" }}
        transition={{ duration: 0.5, ease: [0.19, 1, 0.22, 1] }}
      />
    </div>
  );
}

Update the import paths to match your project setup.

Similar components

Tilt Pointer

Halftone Portrait

Edge Veil Blur

Keycap Hint

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryMicro Interaction
ReactMotionTailwind CSS