- Replaces the `cancelAnimationFrame` pattern with a boolean ticking flag to reduce function allocation and RAF overhead on high-frequency mousemove events. - Uses closure variables for coordinates to ensure the latest position is used in the animation frame. - Improves performance of the custom cursor on high-refresh-rate input devices.
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
import { memo, useEffect, useRef, useState } from 'react';
|
|
import './FancyCursor.css';
|
|
|
|
// This is the new, more performant implementation for the custom cursor.
|
|
// It uses requestAnimationFrame for smooth movement and direct DOM manipulation for speed.
|
|
export const FancyCursor = memo(() => {
|
|
const cursorRef = useRef<HTMLDivElement>(null);
|
|
const [isTouch, setIsTouch] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Check for touch devices once at the beginning. If it's a touch device,
|
|
// the custom cursor will not be rendered.
|
|
if (typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)) {
|
|
setIsTouch(true);
|
|
return;
|
|
}
|
|
|
|
const cursor = cursorRef.current;
|
|
if (!cursor) return;
|
|
|
|
let animationFrameId: number;
|
|
|
|
const updateCursorState = (el: Element | null) => {
|
|
const classList = cursor.classList;
|
|
|
|
// Coercing to boolean with `!!` is a micro-optimization.
|
|
const isResize = !!el?.closest('#custom-resize-handle');
|
|
const isText = !!el?.closest('input[type="text"], input[type="email"], textarea, [contenteditable="true"]');
|
|
const isLink = !!el?.closest('a, button, [role="button"], input[type="submit"], input[type="button"]');
|
|
|
|
// These classes are toggled based on the hovered element.
|
|
// The actual visual styles are defined in your CSS files.
|
|
classList.toggle('resize-hover', isResize);
|
|
classList.toggle('text-hover', isText && !isResize);
|
|
classList.toggle('link-hover', isLink && !isText && !isResize);
|
|
};
|
|
|
|
const handleMouseOver = (e: MouseEvent) => {
|
|
updateCursorState(e.target as Element);
|
|
};
|
|
|
|
// Optimization: Use a ticking flag and closure variables to prevent
|
|
// excessive object creation and RAF churn during high-frequency events.
|
|
let ticking = false;
|
|
let clientX = 0;
|
|
let clientY = 0;
|
|
|
|
const updateCursor = () => {
|
|
// The offset issue was caused by positioning the cursor's top-left corner
|
|
// at the mouse coordinates. To fix this, `translate(-50%, -50%)` is added.
|
|
// This shifts the cursor element by half its own width and height,
|
|
// which effectively centers it on the pointer without affecting the visuals.
|
|
cursor.style.transform = `translate3d(${clientX}px, ${clientY}px, 0) translate(-50%, -50%)`;
|
|
ticking = false;
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
clientX = e.clientX;
|
|
clientY = e.clientY;
|
|
|
|
if (!ticking) {
|
|
animationFrameId = requestAnimationFrame(updateCursor);
|
|
ticking = true;
|
|
}
|
|
};
|
|
|
|
// Using a passive event listener can improve scrolling performance.
|
|
window.addEventListener('mousemove', handleMouseMove, { passive: true });
|
|
window.addEventListener('mouseover', handleMouseOver, { passive: true });
|
|
|
|
// The cleanup function removes the event listener when the component unmounts
|
|
// to prevent memory leaks.
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseover', handleMouseOver);
|
|
cancelAnimationFrame(animationFrameId);
|
|
};
|
|
}, []); // The empty dependency array ensures this effect runs only once.
|
|
|
|
if (isTouch) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
// `will-change` is a hint to the browser to optimize for transform changes.
|
|
<div ref={cursorRef} id="fancy-cursor" style={{ willChange: 'transform' }}>
|
|
<div className="fancy-cursor-dot" />
|
|
{/* SVG updated to use currentColor to inherit from CSS */}
|
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M5 19L19 5M5 19L9 19M5 19L5 15M19 5L15 5M19 5L19 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
});
|