148 lines
4.5 KiB
TypeScript
148 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const HOVER_SELECTOR =
|
|
'a, button, [role="button"], input, textarea, select, summary, [data-cursor-hover]';
|
|
|
|
export function CustomCursor() {
|
|
const dotRef = useRef<HTMLDivElement>(null);
|
|
const ringRef = useRef<HTMLDivElement>(null);
|
|
const [enabled, setEnabled] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Only enable on desktop pointers (>= 900px and fine pointer)
|
|
const mq = window.matchMedia('(min-width: 900px) and (pointer: fine)');
|
|
const apply = () => {
|
|
const on = mq.matches;
|
|
setEnabled(on);
|
|
document.documentElement.classList.toggle('has-cursor', on);
|
|
};
|
|
apply();
|
|
mq.addEventListener('change', apply);
|
|
return () => mq.removeEventListener('change', apply);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
let dotX = window.innerWidth / 2;
|
|
let dotY = window.innerHeight / 2;
|
|
let ringX = dotX;
|
|
let ringY = dotY;
|
|
let raf = 0;
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
dotX = e.clientX;
|
|
dotY = e.clientY;
|
|
};
|
|
|
|
const tick = () => {
|
|
// Ring lags the dot — trailing effect.
|
|
ringX += (dotX - ringX) * 0.18;
|
|
ringY += (dotY - ringY) * 0.18;
|
|
if (dotRef.current) {
|
|
dotRef.current.style.transform = `translate3d(${dotX}px, ${dotY}px, 0) translate(-50%, -50%)`;
|
|
}
|
|
if (ringRef.current) {
|
|
ringRef.current.style.transform = `translate3d(${ringX}px, ${ringY}px, 0) translate(-50%, -50%)`;
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
|
|
const onOver = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement | null;
|
|
const isHover = !!target?.closest(HOVER_SELECTOR);
|
|
ringRef.current?.classList.toggle('cursor-ring--hover', isHover);
|
|
dotRef.current?.classList.toggle('cursor-dot--hover', isHover);
|
|
};
|
|
|
|
const onDown = () => ringRef.current?.classList.add('cursor-ring--down');
|
|
const onUp = () => ringRef.current?.classList.remove('cursor-ring--down');
|
|
const onLeave = () => {
|
|
ringRef.current?.classList.add('cursor--hidden');
|
|
dotRef.current?.classList.add('cursor--hidden');
|
|
};
|
|
const onEnter = () => {
|
|
ringRef.current?.classList.remove('cursor--hidden');
|
|
dotRef.current?.classList.remove('cursor--hidden');
|
|
};
|
|
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseover', onOver);
|
|
window.addEventListener('mousedown', onDown);
|
|
window.addEventListener('mouseup', onUp);
|
|
document.addEventListener('mouseleave', onLeave);
|
|
document.addEventListener('mouseenter', onEnter);
|
|
raf = requestAnimationFrame(tick);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseover', onOver);
|
|
window.removeEventListener('mousedown', onDown);
|
|
window.removeEventListener('mouseup', onUp);
|
|
document.removeEventListener('mouseleave', onLeave);
|
|
document.removeEventListener('mouseenter', onEnter);
|
|
cancelAnimationFrame(raf);
|
|
};
|
|
}, [enabled]);
|
|
|
|
if (!enabled) return null;
|
|
|
|
return (
|
|
<>
|
|
<style jsx global>{`
|
|
.cursor-dot,
|
|
.cursor-ring {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
pointer-events: none;
|
|
z-index: 9999;
|
|
will-change: transform;
|
|
transition:
|
|
width 0.25s ease,
|
|
height 0.25s ease,
|
|
background 0.25s ease,
|
|
border-color 0.25s ease,
|
|
opacity 0.25s ease;
|
|
}
|
|
.cursor-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: #38bdf8;
|
|
box-shadow: 0 0 14px rgba(56, 189, 248, 0.8);
|
|
mix-blend-mode: screen;
|
|
}
|
|
.cursor-ring {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 999px;
|
|
border: 1.5px solid rgba(56, 189, 248, 0.55);
|
|
}
|
|
.cursor-dot--hover {
|
|
background: #e879f9;
|
|
box-shadow: 0 0 18px rgba(232, 121, 249, 0.85);
|
|
}
|
|
.cursor-ring--hover {
|
|
width: 56px;
|
|
height: 56px;
|
|
border-color: rgba(232, 121, 249, 0.7);
|
|
background: rgba(232, 121, 249, 0.05);
|
|
}
|
|
.cursor-ring--down {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
.cursor--hidden {
|
|
opacity: 0;
|
|
}
|
|
`}</style>
|
|
<div ref={ringRef} className="cursor-ring" aria-hidden />
|
|
<div ref={dotRef} className="cursor-dot" aria-hidden />
|
|
</>
|
|
);
|
|
}
|