Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ThemeProvider>
<CustomCursor />
<div className="min-h-screen bg-white dark:bg-gray-900 transition-colors overflow-x-hidden">
<Navbar />
<div className="h-16 border-b border-gray-200/30 dark:border-gray-700/30 bg-gradient-to-b from-white/50 to-transparent dark:from-gray-900/50" />
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/Projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="p-4 lg:p-6">
<div className="flex items-start justify-between mb-4">
Expand Down
296 changes: 296 additions & 0 deletions src/components/ui/CustomCursor.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends (...args: any[]) => any>(
func: T,
delay: number
): T {
let lastCall = 0;
return ((...args: Parameters<T>) => {
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<HTMLElement | null>(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<CursorPosition[]>([
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
]);

const [trails, setTrails] = useState<CursorPosition[]>(trailPositions.current);
const animationFrameRef = useRef<number>();

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 */}
<motion.div
className="fixed top-0 left-0 pointer-events-none z-[9999] mix-blend-difference will-change-transform"
style={{
x: cursorXSpring,
y: cursorYSpring,
}}
animate={{
scale: isHidden ? 0 : isPointer ? 1.5 : 1,
opacity: isHidden ? 0 : cursorOpacity,
}}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
>
<div
className={`relative -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors ${
theme === 'dark' ? 'bg-white' : 'bg-gray-900'
}`}
style={{
width: `${cursorSize}px`,
height: `${cursorSize}px`,
}}
>
{isPointer && !isProjectCard && (
<div
className={`absolute inset-0 rounded-full animate-pulse ${
theme === 'dark' ? 'bg-white/20' : 'bg-gray-900/20'
}`}
style={{
transform: 'scale(1.5)',
}}
/>
)}
{isProjectCard && (
<div className="absolute inset-0 flex items-center justify-center">
<span className={`text-xs font-medium ${
theme === 'dark' ? 'text-black' : 'text-white'
}`}>View</span>
</div>
)}
</div>
</motion.div>

{/* Trail dots */}
{trails.map((trail, index) => (
<motion.div
key={index}
className="fixed top-0 left-0 pointer-events-none z-[9998] mix-blend-difference will-change-transform"
initial={{ opacity: 0 }}
animate={{
x: trail.x,
y: trail.y,
opacity: isHidden ? 0 : 0.5 - index * 0.1,
}}
transition={{
type: "spring",
damping: 30,
stiffness: 200,
delay: index * 0.02,
}}
>
<div
className={`relative -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors ${
theme === 'dark' ? 'bg-white' : 'bg-gray-900'
}`}
style={{
width: `${8 - index}px`,
height: `${8 - index}px`,
}}
/>
</motion.div>
))}
</>
);
}
42 changes: 42 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}