diff --git a/src/App.tsx b/src/App.tsx index cd2ec69..0bc8685 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,12 @@ import { Experience } from "./components/sections/Experience"; import { Projects } from "./components/sections/Projects"; import { Contact } from "./components/sections/Contact"; import { ThemeProvider } from "./contexts/ThemeContext"; +import { CustomCursor } from "./components/ui/CustomCursor"; function App() { return ( +
diff --git a/src/components/sections/Projects.tsx b/src/components/sections/Projects.tsx index 519f731..319022f 100644 --- a/src/components/sections/Projects.tsx +++ b/src/components/sections/Projects.tsx @@ -30,7 +30,7 @@ export function Projects() { whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: index * 0.1 }} viewport={{ once: true }} - className="bg-gray-50 dark:bg-gray-900 rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-shadow w-full" + className="bg-gray-50 dark:bg-gray-900 rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-shadow w-full custom-cursor-project" >
diff --git a/src/components/ui/CustomCursor.tsx b/src/components/ui/CustomCursor.tsx new file mode 100644 index 0000000..87c3879 --- /dev/null +++ b/src/components/ui/CustomCursor.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface CursorPosition { + x: number; + y: number; +} + +// Throttle function for performance +function throttle any>( + func: T, + delay: number +): T { + let lastCall = 0; + return ((...args: Parameters) => { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + return func(...args); + } + }) as T; +} + +export function CustomCursor() { + const [isPointer, setIsPointer] = useState(false); + const [isHidden, setIsHidden] = useState(false); + const [isTextArea, setIsTextArea] = useState(false); + const [isTouchDevice, setIsTouchDevice] = useState(false); + const [magneticTarget, setMagneticTarget] = useState(null); + const [isProjectCard, setIsProjectCard] = useState(false); + const { theme } = useTheme(); + + const cursorX = useMotionValue(0); + const cursorY = useMotionValue(0); + + const springConfig = { damping: 25, stiffness: 700 }; + const cursorXSpring = useSpring(cursorX, springConfig); + const cursorYSpring = useSpring(cursorY, springConfig); + + // Trail positions + const trailPositions = useRef([ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]); + + const [trails, setTrails] = useState(trailPositions.current); + const animationFrameRef = useRef(); + + useEffect(() => { + // Check if it's a touch device + const checkTouchDevice = () => { + setIsTouchDevice( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + window.matchMedia('(pointer: coarse)').matches + ); + }; + + checkTouchDevice(); + window.addEventListener('resize', checkTouchDevice); + + return () => window.removeEventListener('resize', checkTouchDevice); + }, []); + + // Magnetic effect calculation + const applyMagneticEffect = useCallback((mouseX: number, mouseY: number) => { + if (!magneticTarget) { + cursorX.set(mouseX); + cursorY.set(mouseY); + return; + } + + const rect = magneticTarget.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const distanceX = mouseX - centerX; + const distanceY = mouseY - centerY; + const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); + + const magneticRadius = 50; + const magneticStrength = 0.25; + + if (distance < magneticRadius) { + const factor = 1 - (distance / magneticRadius); + const magnetX = mouseX - (distanceX * factor * magneticStrength); + const magnetY = mouseY - (distanceY * factor * magneticStrength); + + cursorX.set(magnetX); + cursorY.set(magnetY); + } else { + cursorX.set(mouseX); + cursorY.set(mouseY); + } + }, [magneticTarget, cursorX, cursorY]); + + const updateTrails = useCallback(() => { + setTrails([...trailPositions.current]); + }, []); + + const throttledUpdateTrails = throttle(updateTrails, 16); // 60fps + + useEffect(() => { + if (isTouchDevice) return; + + const moveCursor = (e: MouseEvent) => { + // Cancel any pending animation frame + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + applyMagneticEffect(e.clientX, e.clientY); + + // Update trail positions with delay + trailPositions.current = [ + { x: e.clientX, y: e.clientY }, + ...trailPositions.current.slice(0, -1), + ]; + + throttledUpdateTrails(); + }); + }; + + const handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Check for interactive elements + const isInteractive = + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.closest('button') || + target.closest('a') || + target.classList.contains('cursor-pointer') || + target.closest('.cursor-pointer') || + target.closest('[role="button"]'); + + // Check for project cards + const projectCard = target.closest('.custom-cursor-project'); + + // Check for text areas + const isText = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.classList.contains('text-content') || + target.tagName === 'P' || + target.tagName === 'H1' || + target.tagName === 'H2' || + target.tagName === 'H3'; + + setIsPointer(!!(isInteractive || projectCard)); + setIsTextArea(!!isText); + setIsProjectCard(!!projectCard); + + // Set magnetic target for buttons and links + if (isInteractive && !projectCard) { + const interactiveElement = target.tagName === 'BUTTON' || target.tagName === 'A' + ? target + : target.closest('button') || target.closest('a'); + setMagneticTarget(interactiveElement as HTMLElement); + } else { + setMagneticTarget(null); + } + }; + + const handleMouseLeave = () => { + setIsHidden(true); + }; + + const handleMouseEnter = () => { + setIsHidden(false); + }; + + // Add event listeners with throttling + const throttledMoveCursor = throttle(moveCursor, 4); // 250fps max + document.addEventListener('mousemove', throttledMoveCursor); + document.addEventListener('mouseover', handleMouseOver); + document.body.addEventListener('mouseleave', handleMouseLeave); + document.body.addEventListener('mouseenter', handleMouseEnter); + + // Hide default cursor + document.body.style.cursor = 'none'; + + // Add cursor-none class to all interactive elements + const style = document.createElement('style'); + style.textContent = ` + * { + cursor: none !important; + } + + /* Performance optimization */ + .custom-cursor-hide { + cursor: none !important; + } + `; + document.head.appendChild(style); + + return () => { + document.removeEventListener('mousemove', throttledMoveCursor); + document.removeEventListener('mouseover', handleMouseOver); + document.body.removeEventListener('mouseleave', handleMouseLeave); + document.body.removeEventListener('mouseenter', handleMouseEnter); + document.body.style.cursor = 'auto'; + style.remove(); + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [applyMagneticEffect, throttledUpdateTrails, isTouchDevice]); + + // Don't render on touch devices + if (isTouchDevice) return null; + + const cursorSize = isProjectCard ? 48 : isPointer ? 24 : isTextArea ? 6 : 12; + const cursorOpacity = isTextArea ? 0.5 : 1; + + return ( + <> + {/* Main cursor */} + +
+ {isPointer && !isProjectCard && ( +
+ )} + {isProjectCard && ( +
+ View +
+ )} +
+ + + {/* Trail dots */} + {trails.map((trail, index) => ( + +
+ + ))} + + ); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index f7d2e90..677f92c 100644 --- a/src/index.css +++ b/src/index.css @@ -239,3 +239,45 @@ img { print-color-adjust: exact !important; } } + +/* Custom Cursor Animations */ +@keyframes cursorGlow { + 0%, 100% { + opacity: 0.5; + transform: scale(1.5); + } + 50% { + opacity: 0.8; + transform: scale(1.8); + } +} + +/* Hide cursor on specific elements */ +input[type="text"], +input[type="email"], +textarea { + caret-color: auto; +} + +/* Smooth transitions for interactive elements */ +button, +a, +.custom-cursor-project { + transition: transform 0.2s ease; +} + +/* Subtle scale on hover for better feedback */ +button:hover, +a:hover { + transform: scale(1.02); +} + +.custom-cursor-project:hover { + transform: translateY(-2px); +} + +/* Ensure cursor stays visible over all elements */ +.custom-cursor { + pointer-events: none !important; + z-index: 10000 !important; +}