Add custom cursor; improve pointer events

This commit is contained in:
2025-04-21 14:15:08 -07:00
parent c6aa014d29
commit 7cc954ae07
7 changed files with 302 additions and 6 deletions

View 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;

View File

@@ -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>

View File

@@ -87,16 +87,19 @@ export default function Header() {
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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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 />

View File

@@ -2,6 +2,11 @@
@tailwind components;
@tailwind utilities;
/* Hide default cursor globally */
* {
cursor: none !important;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
display: none;