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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,10 +10,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-VO0wVEDp.js"></script> <script type="module" crossorigin src="/assets/index-BR63qlJT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/three-eMwtdKkp.js"> <link rel="modulepreload" crossorigin href="/assets/three-1foLNQd8.js">
<link rel="modulepreload" crossorigin href="/assets/motion-BIOHP8Ul.js"> <link rel="modulepreload" crossorigin href="/assets/motion-BIOHP8Ul.js">
<link rel="stylesheet" crossorigin href="/assets/index-BAFm1LaZ.css"> <link rel="stylesheet" crossorigin href="/assets/index-CKQM2HIs.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,6 +1,6 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { LanguageProvider } from './i18n'; import { LanguageProvider } from './i18n';
import { Navbar, Footer, CustomCursor } from './components/layout'; import { Navbar, Footer, FancyCursor } from './components/layout';
import { Home, About, Contact } from './pages'; import { Home, About, Contact } from './pages';
import './styles/global.css'; import './styles/global.css';
@@ -8,7 +8,7 @@ export function App() {
return ( return (
<LanguageProvider> <LanguageProvider>
<BrowserRouter> <BrowserRouter>
<CustomCursor /> <FancyCursor />
<Navbar /> <Navbar />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />

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 { Navbar } from './Navbar';
export { Footer } from './Footer'; export { Footer } from './Footer';
export { CustomCursor } from './CustomCursor'; export { FancyCursor } from './FancyCursor';