Merge pull request #43 from onmax/crosshair

Add Crosshair animation component
This commit is contained in:
David
2025-07-19 15:00:05 +03:00
committed by GitHub
5 changed files with 413 additions and 1 deletions

View File

@@ -0,0 +1,249 @@
<template>
<div
ref="cursorRef"
:style="{
position: containerRef ? 'absolute' : 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 10000
}"
>
<svg
:style="{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%'
}"
>
<defs>
<filter id="filter-noise-x">
<feTurbulence
type="fractalNoise"
baseFrequency="0.000001"
numOctaves="1"
ref="filterXRef"
/>
<feDisplacementMap in="SourceGraphic" scale="40" />
</filter>
<filter id="filter-noise-y">
<feTurbulence
type="fractalNoise"
baseFrequency="0.000001"
numOctaves="1"
ref="filterYRef"
/>
<feDisplacementMap in="SourceGraphic" scale="40" />
</filter>
</defs>
</svg>
<div
ref="lineHorizontalRef"
:style="{
position: 'absolute',
width: '100%',
height: '1px',
background: color,
pointerEvents: 'none',
transform: 'translateY(50%)',
opacity: 0
}"
/>
<div
ref="lineVerticalRef"
:style="{
position: 'absolute',
height: '100%',
width: '1px',
background: color,
pointerEvents: 'none',
transform: 'translateX(50%)',
opacity: 0
}"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef, watchEffect, type Ref } from 'vue';
import { gsap } from 'gsap';
interface Props {
color?: string;
containerRef?: HTMLElement | null;
}
const props = withDefaults(defineProps<Props>(), {
color: 'white',
containerRef: null
});
const cursorRef = useTemplateRef<HTMLDivElement>('cursorRef');
const lineHorizontalRef = useTemplateRef<HTMLDivElement>('lineHorizontalRef');
const lineVerticalRef = useTemplateRef<HTMLDivElement>('lineVerticalRef');
const filterXRef = useTemplateRef<SVGFETurbulenceElement>('filterXRef');
const filterYRef = useTemplateRef<SVGFETurbulenceElement>('filterYRef');
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b;
const getMousePos = (e: MouseEvent, container: HTMLElement | null) => {
if (container) {
const bounds = container.getBoundingClientRect();
return {
x: e.clientX - bounds.left,
y: e.clientY - bounds.top
};
}
return { x: e.clientX, y: e.clientY };
};
let mouse = { x: 0, y: 0 };
let animationId: number | null = null;
let cleanup: (() => void) | null = null;
onMounted(() => {
watchEffect(() => {
if (cleanup) {
cleanup();
}
const handleMouseMove = (ev: Event) => {
const mouseEvent = ev as MouseEvent;
mouse = getMousePos(mouseEvent, props.containerRef);
if (props.containerRef) {
const bounds = props.containerRef.getBoundingClientRect();
if (
mouseEvent.clientX < bounds.left ||
mouseEvent.clientX > bounds.right ||
mouseEvent.clientY < bounds.top ||
mouseEvent.clientY > bounds.bottom
) {
gsap.to([lineHorizontalRef.value, lineVerticalRef.value], { opacity: 0 });
} else {
gsap.to([lineHorizontalRef.value, lineVerticalRef.value], { opacity: 1 });
}
}
};
const target = props.containerRef || window;
target.addEventListener('mousemove', handleMouseMove);
const renderedStyles: {
[key: string]: { previous: number; current: number; amt: number };
} = {
tx: { previous: 0, current: 0, amt: 0.15 },
ty: { previous: 0, current: 0, amt: 0.15 }
};
gsap.set([lineHorizontalRef.value, lineVerticalRef.value], { opacity: 0 });
const onMouseMove = () => {
renderedStyles.tx.previous = renderedStyles.tx.current = mouse.x;
renderedStyles.ty.previous = renderedStyles.ty.current = mouse.y;
gsap.to([lineHorizontalRef.value, lineVerticalRef.value], {
duration: 0.9,
ease: 'Power3.easeOut',
opacity: 1
});
if (animationId === null) {
animationId = requestAnimationFrame(render);
}
target.removeEventListener('mousemove', onMouseMove);
};
target.addEventListener('mousemove', onMouseMove);
const primitiveValues = { turbulence: 0 };
const tl = gsap
.timeline({
paused: true,
onStart: () => {
if (lineHorizontalRef.value && lineVerticalRef.value) {
lineHorizontalRef.value.style.filter = 'url(#filter-noise-x)';
lineVerticalRef.value.style.filter = 'url(#filter-noise-y)';
}
},
onUpdate: () => {
if (filterXRef.value && filterYRef.value) {
filterXRef.value.setAttribute('baseFrequency', primitiveValues.turbulence.toString());
filterYRef.value.setAttribute('baseFrequency', primitiveValues.turbulence.toString());
}
},
onComplete: () => {
if (lineHorizontalRef.value && lineVerticalRef.value) {
lineHorizontalRef.value.style.filter = 'none';
lineVerticalRef.value.style.filter = 'none';
}
}
})
.to(primitiveValues, {
duration: 0.5,
ease: 'power1',
startAt: { turbulence: 1 },
turbulence: 0
});
const enter = () => tl.restart();
const leave = () => tl.progress(1).kill();
const render = () => {
renderedStyles.tx.current = mouse.x;
renderedStyles.ty.current = mouse.y;
for (const key in renderedStyles) {
renderedStyles[key].previous = lerp(
renderedStyles[key].previous,
renderedStyles[key].current,
renderedStyles[key].amt
);
}
if (lineHorizontalRef.value && lineVerticalRef.value) {
gsap.set(lineVerticalRef.value, { x: renderedStyles.tx.previous });
gsap.set(lineHorizontalRef.value, { y: renderedStyles.ty.previous });
}
animationId = requestAnimationFrame(render);
};
const links = props.containerRef
? props.containerRef.querySelectorAll('a')
: document.querySelectorAll('a');
links.forEach((link: HTMLAnchorElement) => {
link.addEventListener('mouseenter', enter);
link.addEventListener('mouseleave', leave);
});
cleanup = () => {
target.removeEventListener('mousemove', handleMouseMove);
target.removeEventListener('mousemove', onMouseMove);
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
links.forEach((link: HTMLAnchorElement) => {
link.removeEventListener('mouseenter', enter);
link.removeEventListener('mouseleave', leave);
});
};
});
onUnmounted(() => {
if (cleanup) {
cleanup();
}
});
});
</script>