Interaction
JewelryCursor
Gold dot cursor with a lagging ring that swells over links and buttons.
npx bezel-add add jewelry-cursorpackages/ui/src/interaction/JewelryCursor.tsx · 95 lines
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
type JewelryCursorProps = {
/** CSS selector for the scope that hides the native cursor once ready */
scopeSelector?: string;
/** Dot + ring color */
color?: string;
/** Extra selectors (beyond a/button/[data-cursor-hover]) that expand the ring */
hoverSelector?: string;
};
/**
* Instant gold-dot cursor + lagged ring that expands over interactive targets.
* Mounts only on fine-pointer devices with motion enabled.
*/
export function JewelryCursor({
scopeSelector = "body",
color = "#c9a227",
hoverSelector = "a, button, [data-cursor-hover]",
}: JewelryCursorProps) {
const dotRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!fine || reduce) return;
const dot = dotRef.current;
const ring = ringRef.current;
const scope = document.querySelector(scopeSelector);
if (!dot || !ring || !scope) return;
scope.classList.add("jewelry-cursor-ready");
gsap.set([dot, ring], { xPercent: -50, yPercent: -50, opacity: 1 });
const xRing = gsap.quickTo(ring, "x", { duration: 0.12, ease: "power2.out" });
const yRing = gsap.quickTo(ring, "y", { duration: 0.12, ease: "power2.out" });
const xDot = gsap.quickSetter(dot, "x", "px");
const yDot = gsap.quickSetter(dot, "y", "px");
const move = (e: MouseEvent) => {
xDot(e.clientX);
yDot(e.clientY);
xRing(e.clientX);
yRing(e.clientY);
};
const enter = () =>
gsap.to(ring, { scale: 1.9, opacity: 1, duration: 0.3, ease: "power2.out" });
const leave = () =>
gsap.to(ring, { scale: 1, opacity: 0.6, duration: 0.3, ease: "power2.out" });
window.addEventListener("mousemove", move);
const targets = Array.from(scope.querySelectorAll(hoverSelector));
targets.forEach((t) => {
t.addEventListener("mouseenter", enter);
t.addEventListener("mouseleave", leave);
});
return () => {
scope.classList.remove("jewelry-cursor-ready");
window.removeEventListener("mousemove", move);
targets.forEach((t) => {
t.removeEventListener("mouseenter", enter);
t.removeEventListener("mouseleave", leave);
});
};
}, [scopeSelector, hoverSelector]);
return (
<>
<style>{`
.jewelry-cursor-ready, .jewelry-cursor-ready * { cursor: none !important; }
@media (prefers-reduced-motion: reduce) {
.jewelry-cursor-ready, .jewelry-cursor-ready * { cursor: auto !important; }
}
`}</style>
<div
ref={dotRef}
aria-hidden
className="pointer-events-none fixed left-0 top-0 z-[10000] h-[3px] w-[3px] rounded-full opacity-0"
style={{ background: color }}
/>
<div
ref={ringRef}
aria-hidden
className="pointer-events-none fixed left-0 top-0 z-[10000] h-6 w-6 rounded-full opacity-0"
style={{ border: `1.5px solid ${color}` }}
/>
</>
);
}