mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
Add Crosshair animation component
This commit is contained in:
@@ -49,7 +49,8 @@ export const CATEGORIES = [
|
|||||||
'Blob Cursor',
|
'Blob Cursor',
|
||||||
'Meta Balls',
|
'Meta Balls',
|
||||||
'Image Trail',
|
'Image Trail',
|
||||||
'Shape Blur'
|
'Shape Blur',
|
||||||
|
'Crosshair'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const animations = {
|
|||||||
'image-trail': () => import('../demo/Animations/ImageTrailDemo.vue'),
|
'image-trail': () => import('../demo/Animations/ImageTrailDemo.vue'),
|
||||||
'shape-blur': () => import('../demo/Animations/ShapeBlurDemo.vue'),
|
'shape-blur': () => import('../demo/Animations/ShapeBlurDemo.vue'),
|
||||||
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
|
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
|
||||||
|
'crosshair': () => import('../demo/Animations/CrosshairDemo.vue'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const textAnimations = {
|
const textAnimations = {
|
||||||
|
|||||||
21
src/constants/code/Animations/crosshairCode.ts
Normal file
21
src/constants/code/Animations/crosshairCode.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import code from '@/content/Animations/Crosshair/Crosshair.vue?raw';
|
||||||
|
import type { CodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const crosshair: CodeObject = {
|
||||||
|
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/Crosshair`,
|
||||||
|
installation: `npm i gsap`,
|
||||||
|
usage: `<template>
|
||||||
|
<div ref="containerRef" style="height: 300px; overflow: hidden;">
|
||||||
|
<Crosshair :container-ref="containerElement" color="#ffffff" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef, computed } from 'vue';
|
||||||
|
import Crosshair from "./Crosshair.vue";
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||||
|
const containerElement = computed(() => containerRef.value);
|
||||||
|
</script>`,
|
||||||
|
code
|
||||||
|
};
|
||||||
249
src/content/Animations/Crosshair/Crosshair.vue
Normal file
249
src/content/Animations/Crosshair/Crosshair.vue
Normal 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>
|
||||||
140
src/demo/Animations/CrosshairDemo.vue
Normal file
140
src/demo/Animations/CrosshairDemo.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div ref="containerRef" class="demo-container relative min-h-[300px] overflow-hidden">
|
||||||
|
<Crosshair :container-ref="targeted ? containerElement : null" :color="color" />
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-center items-center">
|
||||||
|
<a
|
||||||
|
ref="linkRef"
|
||||||
|
href="https://github.com/DavidHDev/vue-bits"
|
||||||
|
class="text-center font-black text-[2rem] md:text-[4rem] transition-all duration-300 ease-in-out hover:text-[#5227ff]"
|
||||||
|
:style="{ minWidth: minWidth + 'px' }"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
{{ linkText }}
|
||||||
|
</a>
|
||||||
|
<p class="relative -top-[10px] text-[#444] text-sm">(hover the text)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
ref="hiddenRef"
|
||||||
|
class="absolute invisible whitespace-nowrap pointer-events-none overflow-hidden text-center font-black text-[2rem] md:text-[4rem]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ linkText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewColor title="Crosshair Color" v-model="color" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="bg-[#170D27] hover:bg-[#271E37] text-white text-xs px-3 py-2 border border-[#271E37] rounded-[10px] h-8 transition-colors mt-2"
|
||||||
|
@click="toggleTargeted"
|
||||||
|
>
|
||||||
|
Cursor Container
|
||||||
|
<span :class="targeted ? 'text-green-400' : 'text-orange-400'">
|
||||||
|
{{ targeted ? 'Viewport' : 'Targeted' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['gsap']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="crosshair" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="crosshair.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, useTemplateRef, nextTick, computed } from 'vue';
|
||||||
|
import TabbedLayout from '@/components/common/TabbedLayout.vue';
|
||||||
|
import PropTable from '@/components/common/PropTable.vue';
|
||||||
|
import Dependencies from '@/components/code/Dependencies.vue';
|
||||||
|
import CliInstallation from '@/components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '@/components/code/CodeExample.vue';
|
||||||
|
import Customize from '@/components/common/Customize.vue';
|
||||||
|
import PreviewColor from '@/components/common/PreviewColor.vue';
|
||||||
|
import Crosshair from '@/content/Animations/Crosshair/Crosshair.vue';
|
||||||
|
import { crosshair } from '@/constants/code/Animations/crosshairCode';
|
||||||
|
|
||||||
|
const DEFAULT_TEXT = 'Aim... aand...';
|
||||||
|
|
||||||
|
const linkText = ref(DEFAULT_TEXT);
|
||||||
|
const color = ref('#ffffff');
|
||||||
|
const targeted = ref(true);
|
||||||
|
const minWidth = ref(0);
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||||
|
const linkRef = useTemplateRef<HTMLAnchorElement>('linkRef');
|
||||||
|
const hiddenRef = useTemplateRef<HTMLSpanElement>('hiddenRef');
|
||||||
|
|
||||||
|
const containerElement = computed(() => containerRef.value);
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'color',
|
||||||
|
type: 'string',
|
||||||
|
default: "'white'",
|
||||||
|
description: 'Color of the crosshair lines.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'containerRef',
|
||||||
|
type: 'Ref<HTMLElement | null>',
|
||||||
|
default: 'null',
|
||||||
|
description: 'Optional container ref to limit crosshair to specific element. If null, crosshair will be active on entire viewport.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
linkText.value = 'Shoot!!!';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
linkText.value = DEFAULT_TEXT;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTargeted = () => {
|
||||||
|
targeted.value = !targeted.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMinWidth = async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (hiddenRef.value) {
|
||||||
|
const width = hiddenRef.value.getBoundingClientRect().width;
|
||||||
|
if (minWidth.value < width) {
|
||||||
|
minWidth.value = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateMinWidth();
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (hiddenRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateMinWidth();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(hiddenRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user