add: FancyCursor

This commit is contained in:
Melvin Ragusa
2026-01-22 00:27:57 +01:00
parent 4f07eef844
commit faa92414f1
13 changed files with 296 additions and 285 deletions

View File

@@ -1,67 +0,0 @@
.cursor {
position: fixed;
width: 40px;
height: 40px;
border: 2px solid var(--md-sys-color-primary);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition:
width 0.15s ease-out,
height 0.15s ease-out,
border-color 0.15s ease-out,
background-color 0.15s ease-out,
opacity 0.15s ease-out;
mix-blend-mode: difference;
}
.cursorDot {
position: fixed;
width: 6px;
height: 6px;
background-color: var(--md-sys-color-primary);
border-radius: 50%;
pointer-events: none;
z-index: 10000;
transform: translate(-50%, -50%);
transition: opacity 0.15s ease-out;
}
.cursor.pointer {
width: 60px;
height: 60px;
border-color: var(--md-sys-color-primary);
background-color: rgba(127, 217, 152, 0.1);
}
.cursor.clicking {
width: 35px;
height: 35px;
background-color: rgba(127, 217, 152, 0.2);
}
.cursor.hidden,
.cursorDot.hidden {
opacity: 0;
}
/* Hide on touch devices and when reduced motion is preferred */
@media (hover: none), (prefers-reduced-motion: reduce) {
.cursor,
.cursorDot {
display: none;
}
}
/* Add global cursor hide for devices with custom cursor */
@media (hover: hover) {
:global(body) {
cursor: none;
}
:global(a),
:global(button) {
cursor: none;
}
}

View File

@@ -1,74 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import styles from './CustomCursor.module.css';
export function CustomCursor() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isPointer, setIsPointer] = useState(false);
const [isHidden, setIsHidden] = useState(true);
const [isClicking, setIsClicking] = useState(false);
const handleMouseMove = useCallback((e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
setIsHidden(false);
const target = e.target as HTMLElement;
const isClickable =
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
!!target.closest('a') ||
!!target.closest('button') ||
window.getComputedStyle(target).cursor === 'pointer';
setIsPointer(isClickable);
}, []);
const handleMouseDown = useCallback(() => setIsClicking(true), []);
const handleMouseUp = useCallback(() => setIsClicking(false), []);
const handleMouseLeave = useCallback(() => setIsHidden(true), []);
const handleMouseEnter = useCallback(() => setIsHidden(false), []);
useEffect(() => {
// Don't show custom cursor on touch devices
if (window.matchMedia('(hover: none)').matches) {
return;
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseenter', handleMouseEnter);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseenter', handleMouseEnter);
};
}, [handleMouseMove, handleMouseDown, handleMouseUp, handleMouseLeave, handleMouseEnter]);
// Don't render on touch devices
if (typeof window !== 'undefined' && window.matchMedia('(hover: none)').matches) {
return null;
}
return (
<>
<div
className={`${styles.cursor} ${isPointer ? styles.pointer : ''} ${isHidden ? styles.hidden : ''} ${isClicking ? styles.clicking : ''}`}
style={{
left: position.x,
top: position.y,
}}
/>
<div
className={`${styles.cursorDot} ${isHidden ? styles.hidden : ''}`}
style={{
left: position.x,
top: position.y,
}}
/>
</>
);
}

View File

@@ -0,0 +1,77 @@
:root {
--cursor-size: 20px;
--cursor-dot-size: 8px;
}
#fancy-cursor {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
pointer-events: none;
mix-blend-mode: difference;
display: flex;
align-items: center;
justify-content: center;
}
.fancy-cursor-dot {
width: var(--cursor-dot-size);
height: var(--cursor-dot-size);
background-color: var(--md-sys-color-primary);
border-radius: 50%;
transition: transform 0.2s ease, opacity 0.2s, width 0.2s, height 0.2s, border-radius 0.2s;
}
#fancy-cursor svg {
position: absolute;
width: 32px;
height: 32px;
opacity: 0;
transform: scale(0.5) rotate(0deg);
transition: opacity 0.2s, transform 0.2s;
stroke: var(--md-sys-color-primary);
}
/* Link Hover State */
#fancy-cursor.link-hover .fancy-cursor-dot {
transform: scale(2.5);
opacity: 0.5;
}
/* Text Hover State */
#fancy-cursor.text-hover .fancy-cursor-dot {
width: 2px;
height: 20px;
border-radius: 1px;
transform: scale(1);
}
/* Resize Hover State */
#fancy-cursor.resize-hover .fancy-cursor-dot {
opacity: 0;
}
#fancy-cursor.resize-hover svg {
opacity: 1;
transform: scale(1) rotate(0deg);
}
/* Global cursor hiding */
@media (hover: hover) and (pointer: fine) {
body,
a,
button,
input,
textarea,
[role="button"] {
cursor: none !important;
}
}
/* Hide custom cursor on touch/no-hover devices */
@media (hover: none) {
#fancy-cursor {
display: none !important;
}
}

View File

@@ -0,0 +1,75 @@
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;
// The mouse move handler is throttled with requestAnimationFrame to ensure
// the animation is smooth and doesn't cause performance issues.
const handleMouseMove = (e: MouseEvent) => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(() => {
// 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(${e.clientX}px, ${e.clientY}px, 0) translate(-50%, -50%)`;
const el = document.elementFromPoint(e.clientX, e.clientY);
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);
});
};
// Using a passive event listener can improve scrolling performance.
window.addEventListener('mousemove', handleMouseMove, { passive: true });
// The cleanup function removes the event listener when the component unmounts
// to prevent memory leaks.
return () => {
window.removeEventListener('mousemove', handleMouseMove);
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>
);
});

View File

@@ -1,3 +1,3 @@
export { Navbar } from './Navbar';
export { Footer } from './Footer';
export { CustomCursor } from './CustomCursor';
export { FancyCursor } from './FancyCursor';