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
Loading...
demo-clipped-circle.tsx
"use client";

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

interface ClippedCircleDemoProps {
  circleSize: number;
}

export default function ClippedCircleDemo({
  circleSize = 400,
}: ClippedCircleDemoProps) {
  const actions = [
    { label: "Docs", variant: "default" },
    { label: "Install", variant: "outline" },
    { label: "Components", variant: "ghost" },
  ] as const;

  return (
    <div className="mx-auto flex w-full max-w-xl flex-col gap-5 p-6">
      <div className="grid grid-cols-3 gap-3">
        {actions.map(({ label, variant }) => (
          <Button
            key={label}
            variant={variant}
            size="sm"
            className="relative overflow-hidden"
          >
            {label}
            <ClippedCircle circleSize={260} circleClassName="bg-white" />
          </Button>
        ))}
      </div>

      <div className="relative min-h-75 overflow-hidden rounded-2xl border border-border/70 bg-[radial-gradient(circle_at_top_left,hsl(var(--primary)/0.16),transparent_38%),linear-gradient(135deg,hsl(var(--muted)),hsl(var(--background)))] p-8 shadow-sm">
        <div className="flex h-full min-h-59 items-center justify-center rounded-xl border border-border/60 bg-white p-10 shadow-inner">
          <img
            src="/docs/unlumen-ui.svg"
            alt="Unlumen UI"
            className="h-auto w-full max-w-52 object-contain"
          />
        </div>
        <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/primitives/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

PropTypeDefaultDescription
circleSize?number400Diameter of the spotlight circle in pixels.
circleClassName?string"bg-white/20"Tailwind class(es) applied to the circle element. Use bg-* classes to control color and opacity.
className?string-Class applied to the wrapper div (absolute inset-0).

Built by Léo

Last updated: 5/14/2026

On this page

Need beautifully designed components ready to go?

If you liked the docs, you'll love the components.