mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 11:03:50 +00:00
Add custom cursor; improve pointer events
This commit is contained in:
278
src/src/components/cursor/index.tsx
Normal file
278
src/src/components/cursor/index.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface CursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
isPointer: boolean;
|
||||
isClicking: boolean;
|
||||
isOverBackground: boolean;
|
||||
isOverIframe: boolean;
|
||||
}
|
||||
|
||||
const Cursor: React.FC = () => {
|
||||
const [state, setState] = useState<CursorState>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPointer: false,
|
||||
isClicking: false,
|
||||
isOverBackground: false,
|
||||
isOverIframe: false
|
||||
});
|
||||
|
||||
const cursorRef = useRef<HTMLDivElement>(null);
|
||||
const requestRef = useRef<number>();
|
||||
const targetX = useRef(0);
|
||||
const targetY = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCursorPosition = (e: MouseEvent) => {
|
||||
targetX.current = e.clientX;
|
||||
targetY.current = e.clientY;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if we're over an iframe
|
||||
const isOverIframe = target.tagName === 'IFRAME' ||
|
||||
target.closest('iframe') !== null;
|
||||
|
||||
// Check if the element is interactive
|
||||
const isInteractive = target.tagName === 'A' || target.tagName === 'BUTTON' ||
|
||||
target.closest('a') !== null || target.closest('button') !== null ||
|
||||
window.getComputedStyle(target).cursor === 'pointer';
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
isPointer: isInteractive,
|
||||
isOverBackground: target.tagName === 'CANVAS' ||
|
||||
target.closest('canvas') !== null,
|
||||
isOverIframe: isOverIframe
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
setState(prev => ({ ...prev, isClicking: true }));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setState(prev => ({ ...prev, isClicking: false }));
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setState(prev => ({ ...prev, x: -100, y: -100, isClicking: false }));
|
||||
};
|
||||
|
||||
// Handle iframe mouse enter/leave
|
||||
const handleFrameEnter = () => {
|
||||
setState(prev => ({ ...prev, isOverIframe: true }));
|
||||
};
|
||||
|
||||
const handleFrameLeave = () => {
|
||||
setState(prev => ({ ...prev, isOverIframe: false }));
|
||||
};
|
||||
|
||||
// Smooth cursor movement animation
|
||||
const animate = () => {
|
||||
if (cursorRef.current) {
|
||||
const currentX = parseFloat(cursorRef.current.style.left || '0');
|
||||
const currentY = parseFloat(cursorRef.current.style.top || '0');
|
||||
|
||||
// Smooth interpolation
|
||||
const newX = currentX + (targetX.current - currentX) * 0.2;
|
||||
const newY = currentY + (targetY.current - currentY) * 0.2;
|
||||
|
||||
cursorRef.current.style.left = newX + 'px';
|
||||
cursorRef.current.style.top = newY + 'px';
|
||||
}
|
||||
requestRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousemove', updateCursorPosition);
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// Add iframe detection listeners
|
||||
const iframes = document.getElementsByTagName('iframe');
|
||||
Array.from(iframes).forEach(iframe => {
|
||||
iframe.addEventListener('mouseenter', handleFrameEnter);
|
||||
iframe.addEventListener('mouseleave', handleFrameLeave);
|
||||
});
|
||||
|
||||
// Start animation
|
||||
requestRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', updateCursorPosition);
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// Remove iframe listeners
|
||||
Array.from(iframes).forEach(iframe => {
|
||||
iframe.removeEventListener('mouseenter', handleFrameEnter);
|
||||
iframe.removeEventListener('mouseleave', handleFrameLeave);
|
||||
});
|
||||
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper function to get color from class names
|
||||
const getColorFromClass = (element: Element) => {
|
||||
const classes = element.className;
|
||||
if (typeof classes !== 'string') return null;
|
||||
|
||||
// Map of class names to color values
|
||||
const colorMap: { [key: string]: string } = {
|
||||
'text-aqua': '#689d6a',
|
||||
'text-green': '#98971a',
|
||||
'text-yellow': '#d79921',
|
||||
'text-blue': '#458588',
|
||||
'text-purple': '#b16286',
|
||||
'text-red': '#cc241d',
|
||||
'text-orange': '#d65d0e',
|
||||
// Bright variants
|
||||
'hover:text-aqua': '#8ec07c',
|
||||
'hover:text-green': '#b8bb26',
|
||||
'hover:text-yellow': '#fabd2f',
|
||||
'hover:text-blue': '#83a598',
|
||||
'hover:text-purple': '#d3869b',
|
||||
'hover:text-red': '#fb4934',
|
||||
'hover:text-orange': '#fe8019',
|
||||
};
|
||||
|
||||
// Find the first matching color class
|
||||
for (const [className, color] of Object.entries(colorMap)) {
|
||||
if (classes.includes(className)) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Determine cursor color based on element and state
|
||||
const getCursorColor = () => {
|
||||
if (state.isOverBackground) return '#ebdbb2';
|
||||
|
||||
// Get the element under cursor
|
||||
const elementUnderCursor = document.elementFromPoint(state.x, state.y);
|
||||
if (!elementUnderCursor) return '#ebdbb2';
|
||||
|
||||
// Check element type and apply appropriate color
|
||||
if (elementUnderCursor.tagName === 'A' || elementUnderCursor.closest('a')) {
|
||||
const linkElement = elementUnderCursor.tagName === 'A' ? elementUnderCursor : elementUnderCursor.closest('a')!;
|
||||
|
||||
// First try to get color from class
|
||||
const classColor = getColorFromClass(linkElement);
|
||||
if (classColor) return classColor;
|
||||
|
||||
// Fallback to computed style
|
||||
const computedStyle = window.getComputedStyle(linkElement);
|
||||
return computedStyle.color;
|
||||
}
|
||||
|
||||
if (elementUnderCursor.tagName === 'BUTTON' || elementUnderCursor.closest('button')) {
|
||||
return '#fabd2f'; // yellow.bright
|
||||
}
|
||||
|
||||
if (elementUnderCursor.tagName === 'INPUT' || elementUnderCursor.tagName === 'TEXTAREA') {
|
||||
return '#8ec07c'; // aqua.bright
|
||||
}
|
||||
|
||||
// Check for any element with color classes
|
||||
const classColor = getColorFromClass(elementUnderCursor);
|
||||
if (classColor) return classColor;
|
||||
|
||||
return '#ebdbb2'; // default foreground color
|
||||
};
|
||||
|
||||
const cursorColor = getCursorColor();
|
||||
const scale = state.isClicking ? 0.8 : (state.isPointer ? 1.2 : 1);
|
||||
|
||||
// Hide custom cursor when over iframe
|
||||
if (state.isOverIframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main cursor dot */}
|
||||
<div
|
||||
ref={cursorRef}
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
style={{
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full border transition-all duration-150 ease-out"
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderColor: cursorColor || '#ebdbb2',
|
||||
borderWidth: '2px',
|
||||
backgroundColor: state.isClicking ? cursorColor : 'transparent',
|
||||
transform: `scale(${scale})`,
|
||||
boxShadow: state.isPointer ? `0 0 8px ${cursorColor}40` : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inner cursor dot (for better visibility) */}
|
||||
<div
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
style={{
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
backgroundColor: cursorColor || '#ebdbb2',
|
||||
transform: `scale(${scale})`,
|
||||
transition: 'all 0.15s ease-out',
|
||||
boxShadow: state.isPointer ? `0 0 6px ${cursorColor}` : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hover glow effect */}
|
||||
{state.isPointer && !state.isOverBackground && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[9998]"
|
||||
style={{
|
||||
left: state.x,
|
||||
top: state.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full animate-pulse"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
backgroundColor: `${cursorColor}10`,
|
||||
filter: 'blur(4px)',
|
||||
transform: `scale(${scale})`,
|
||||
transition: 'all 0.2s ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cursor;
|
||||
@@ -12,8 +12,8 @@ export default function Footer({ fixed = false }) {
|
||||
));
|
||||
|
||||
return (
|
||||
<footer className={`w-full font-bold ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
|
||||
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20">
|
||||
<footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
|
||||
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
|
||||
{footerLinks}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -80,23 +80,26 @@ export default function Header() {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`
|
||||
fixed z-50 top-0 left-0 right-0
|
||||
font-bold
|
||||
transition-transform duration-300
|
||||
pointer-events-none
|
||||
${visible ? "translate-y-0" : "-translate-y-full"}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-full flex flex-row items-center justify-center
|
||||
pointer-events-none
|
||||
${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
|
||||
`}>
|
||||
<div className={`
|
||||
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
|
||||
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
|
||||
pointer-events-none [&_a]:pointer-events-auto
|
||||
${!isIndexPage ? 'bg-black md:px-20' : ''}
|
||||
`}>
|
||||
{headerLinks}
|
||||
|
||||
@@ -72,8 +72,8 @@ export default function Hero() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="text-4xl font-bold text-center">
|
||||
<div className="flex justify-center items-center min-h-screen pointer-events-none">
|
||||
<div className="text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
|
||||
<Typewriter
|
||||
options={typewriterOptions}
|
||||
onInit={handleInit}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
---
|
||||
import "@/style/globals.css";
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
import Cursor from "@/components/cursor";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import Background from "@/components/background";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
@@ -48,11 +53,14 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||
<Cursor client:only="react" />
|
||||
<Header client:load />
|
||||
<main class="flex-1 flex flex-col">
|
||||
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1">
|
||||
<Background layout="content" position="right" client:only="react" transition:persist />
|
||||
<slot />
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<Background layout="content" position="left" client:only="react" transition:persist />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@ import "@/style/globals.css";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
import Cursor from "@/components/cursor";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import Background from "@/components/background";
|
||||
@@ -40,6 +41,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="bg-background text-foreground">
|
||||
<Cursor client:only="react" />
|
||||
<Header client:load />
|
||||
<main transition:animate="fade">
|
||||
<Background layout="index" client:only="react" transition:persist />
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Hide default cursor globally */
|
||||
* {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
/* Chrome, Edge, and Safari */
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user