Clipped Circle

A cursor-following spotlight effect that reveals a soft glowing circle wherever the user moves their mouse inside a container.

Made by Léo
Open in

Fast & Lightweight

Built with performance in mind, keeping your bundle size minimal.

Clipped Circle Demo
demo-clipped-circle.tsx
"use client";

import { ClippedCircle } from "@/components/unlumen-ui/clipped-circle";
import { Button } from "@/components/ui/button";
import Image from "next/image";

interface ClippedCircleDemoProps {
  circleSize: number;
}

export default function ClippedCircleDemo({
  circleSize = 400,
}: ClippedCircleDemoProps) {
  return (
    <div className="flex flex-wrap gap-8 items-center justify-center p-8">
      {/* Button */}
      <Button variant="outline" className="overflow-hidden relative group">
        Get Started
        <ClippedCircle circleSize={300} circleClassName="bg-white" />
      </Button>

      {/* Card */}
      <div className="relative text-background border aspect-square overflow-hidden rounded-xl bg-primary p-6 w-64 cursor-default">
        <h3 className="font-medium text-3xl mb-1">Fast & Lightweight</h3>
        <p className="text-background/50 text-xs leading-relaxed">
          Built with performance in mind, keeping your bundle size minimal.
        </p>
        <ClippedCircle circleSize={circleSize} circleClassName="bg-white" />
      </div>

      {/* Image */}
      <div className="relative border-5 overflow-hidden rounded-xl cursor-default">
        <Image
          src="/web-app-manifest-512x512.png"
          width={200}
          height={200}
          alt="Clipped Circle Demo"
          className="w-full h-full object-cover"
        />
        <ClippedCircle circleSize={circleSize} circleClassName="bg-white" />
      </div>
    </div>
  );
}

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/clipped-circle.tsx
"use client";

import * as React from "react";
import { motion } from "motion/react";

import { cn } from "@/lib/utils";

interface ClippedCircleProps {
  className?: string;
  circleClassName?: string;
  circleSize?: number;
}

function ClippedCircle({
  className,
  circleClassName = "bg-white/20",
  circleSize = 400,
}: ClippedCircleProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [isHovered, setIsHovered] = React.useState(false);
  const [position, setPosition] = React.useState({ x: "50%", y: "50%" });

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container || !container.parentElement) return;

    const parent = container.parentElement;

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

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

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

    parent.addEventListener("mouseenter", handleMouseEnter);
    parent.addEventListener("mousemove", handleMouseMove);
    parent.addEventListener("mouseleave", handleMouseLeave);

    return () => {
      parent.removeEventListener("mouseenter", handleMouseEnter);
      parent.removeEventListener("mousemove", handleMouseMove);
      parent.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, []);

  return (
    <div
      ref={containerRef}
      className={cn(
        "absolute inset-0 overflow-hidden pointer-events-none",
        className,
      )}
    >
      <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>
  );
}

export { ClippedCircle, type ClippedCircleProps };

Update the import paths to match your project setup.

Usage

Place ClippedCircle as a direct child of any relative overflow-hidden container. It attaches mouse listeners to its parent element automatically.

import { ClippedCircle } from "@/components/unlumen-ui/components/effects/clipped-circle";

// Inside a button
<button className="relative overflow-hidden ...">
  Click me
  <ClippedCircle circleSize={300} circleClassName="bg-white/25" />
</button>

// Inside a card
<div className="relative overflow-hidden ...">
  <p>Card content</p>
  <ClippedCircle circleSize={500} circleClassName="bg-violet-500/30" />
</div>

API Reference

ClippedCircle

PropTypeDefault
circleSize?
number
400
circleClassName?
string
"bg-white/20"
className?
string
-

Built by Léo from Unlumen :3

Last updated: 3/15/2026