mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge pull request #37 from Utkarsh-Singhal-26/feat/target-cursor
Added <TargetCursor /> Animation
This commit is contained in:
@@ -49,6 +49,7 @@ export const CATEGORIES = [
|
||||
'Meta Balls',
|
||||
'Image Trail',
|
||||
'Shape Blur',
|
||||
'Target Cursor',
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ const animations = {
|
||||
'meta-balls': () => import('../demo/Animations/MetaBallsDemo.vue'),
|
||||
'image-trail': () => import('../demo/Animations/ImageTrailDemo.vue'),
|
||||
'shape-blur': () => import('../demo/Animations/ShapeBlurDemo.vue'),
|
||||
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
|
||||
};
|
||||
|
||||
const textAnimations = {
|
||||
|
||||
22
src/constants/code/Animations/targetCursorCode.ts
Normal file
22
src/constants/code/Animations/targetCursorCode.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import code from '@/content/Animations/TargetCursor/TargetCursor.vue?raw';
|
||||
import type { CodeObject } from '../../../types/code';
|
||||
|
||||
export const targetCursor: CodeObject = {
|
||||
cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/TargetCursor`,
|
||||
installation: `npm install gsap`,
|
||||
usage: `<template>
|
||||
<TargetCursor
|
||||
:spin-duration="2"
|
||||
:hide-default-cursor="true"
|
||||
/>
|
||||
|
||||
<h1>Hover over the elements below</h1>
|
||||
<button class="cursor-target">Click me!</button>
|
||||
<div class="cursor-target">Hover target</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TargetCursor from "./TargetCursor.vue";
|
||||
</script>`,
|
||||
code
|
||||
};
|
||||
339
src/content/Animations/TargetCursor/TargetCursor.vue
Normal file
339
src/content/Animations/TargetCursor/TargetCursor.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import { gsap } from 'gsap';
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
interface TargetCursorProps {
|
||||
targetSelector?: string;
|
||||
spinDuration?: number;
|
||||
hideDefaultCursor?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TargetCursorProps>(), {
|
||||
targetSelector: '.cursor-target',
|
||||
spinDuration: 2,
|
||||
hideDefaultCursor: true
|
||||
});
|
||||
|
||||
const cursorRef = useTemplateRef('cursorRef');
|
||||
const cornersRef = ref<NodeListOf<HTMLDivElement> | null>(null);
|
||||
const spinTl = ref<gsap.core.Timeline | null>(null);
|
||||
|
||||
const constants = {
|
||||
borderWidth: 3,
|
||||
cornerSize: 12,
|
||||
parallaxStrength: 0.00005
|
||||
};
|
||||
|
||||
const moveCursor = (x: number, y: number) => {
|
||||
if (!cursorRef.value) return;
|
||||
|
||||
gsap.to(cursorRef.value, {
|
||||
x,
|
||||
y,
|
||||
duration: 0.1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
};
|
||||
|
||||
let cleanupAnimation: () => void = () => {};
|
||||
|
||||
const setupAnimation = () => {
|
||||
if (!cursorRef.value) return;
|
||||
|
||||
const originalCursor = document.body.style.cursor;
|
||||
if (props.hideDefaultCursor) {
|
||||
document.body.style.cursor = 'none';
|
||||
}
|
||||
|
||||
const cursor = cursorRef.value;
|
||||
cornersRef.value = cursor.querySelectorAll<HTMLDivElement>('.target-cursor-corner');
|
||||
|
||||
let activeTarget: Element | null = null;
|
||||
let currentTargetMove: ((ev: Event) => void) | null = null;
|
||||
let currentLeaveHandler: (() => void) | null = null;
|
||||
let isAnimatingToTarget = false;
|
||||
let resumeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanupTarget = (target: Element) => {
|
||||
if (currentTargetMove) {
|
||||
target.removeEventListener('mousemove', currentTargetMove);
|
||||
}
|
||||
if (currentLeaveHandler) {
|
||||
target.removeEventListener('mouseleave', currentLeaveHandler);
|
||||
}
|
||||
currentTargetMove = null;
|
||||
currentLeaveHandler = null;
|
||||
};
|
||||
|
||||
gsap.set(cursor, {
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2
|
||||
});
|
||||
|
||||
const createSpinTimeline = () => {
|
||||
if (spinTl.value) {
|
||||
spinTl.value.kill();
|
||||
}
|
||||
spinTl.value = gsap
|
||||
.timeline({ repeat: -1 })
|
||||
.to(cursor, { rotation: '+=360', duration: props.spinDuration, ease: 'none' });
|
||||
};
|
||||
|
||||
createSpinTimeline();
|
||||
|
||||
const moveHandler = (e: MouseEvent) => moveCursor(e.clientX, e.clientY);
|
||||
window.addEventListener('mousemove', moveHandler);
|
||||
|
||||
const enterHandler = (e: MouseEvent) => {
|
||||
const directTarget = e.target as Element;
|
||||
|
||||
const allTargets: Element[] = [];
|
||||
let current = directTarget;
|
||||
while (current && current !== document.body) {
|
||||
if (current.matches(props.targetSelector)) {
|
||||
allTargets.push(current);
|
||||
}
|
||||
current = current.parentElement!;
|
||||
}
|
||||
|
||||
const target = allTargets[0] || null;
|
||||
if (!target || !cursorRef.value || !cornersRef.value) return;
|
||||
|
||||
if (activeTarget === target) return;
|
||||
|
||||
if (activeTarget) {
|
||||
cleanupTarget(activeTarget);
|
||||
}
|
||||
|
||||
if (resumeTimeout) {
|
||||
clearTimeout(resumeTimeout);
|
||||
resumeTimeout = null;
|
||||
}
|
||||
|
||||
activeTarget = target;
|
||||
|
||||
gsap.killTweensOf(cursorRef.value, 'rotation');
|
||||
spinTl.value?.pause();
|
||||
|
||||
gsap.set(cursorRef.value, { rotation: 0 });
|
||||
|
||||
const updateCorners = (mouseX?: number, mouseY?: number) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const cursorRect = cursorRef.value!.getBoundingClientRect();
|
||||
|
||||
const cursorCenterX = cursorRect.left + cursorRect.width / 2;
|
||||
const cursorCenterY = cursorRect.top + cursorRect.height / 2;
|
||||
|
||||
const [tlc, trc, brc, blc] = Array.from(cornersRef.value!);
|
||||
|
||||
const { borderWidth, cornerSize, parallaxStrength } = constants;
|
||||
|
||||
const tlOffset = {
|
||||
x: rect.left - cursorCenterX - borderWidth,
|
||||
y: rect.top - cursorCenterY - borderWidth
|
||||
};
|
||||
const trOffset = {
|
||||
x: rect.right - cursorCenterX + borderWidth - cornerSize,
|
||||
y: rect.top - cursorCenterY - borderWidth
|
||||
};
|
||||
const brOffset = {
|
||||
x: rect.right - cursorCenterX + borderWidth - cornerSize,
|
||||
y: rect.bottom - cursorCenterY + borderWidth - cornerSize
|
||||
};
|
||||
const blOffset = {
|
||||
x: rect.left - cursorCenterX - borderWidth,
|
||||
y: rect.bottom - cursorCenterY + borderWidth - cornerSize
|
||||
};
|
||||
|
||||
if (mouseX !== undefined && mouseY !== undefined) {
|
||||
const targetCenterX = rect.left + rect.width / 2;
|
||||
const targetCenterY = rect.top + rect.height / 2;
|
||||
const mouseOffsetX = (mouseX - targetCenterX) * parallaxStrength;
|
||||
const mouseOffsetY = (mouseY - targetCenterY) * parallaxStrength;
|
||||
|
||||
tlOffset.x += mouseOffsetX;
|
||||
tlOffset.y += mouseOffsetY;
|
||||
trOffset.x += mouseOffsetX;
|
||||
trOffset.y += mouseOffsetY;
|
||||
brOffset.x += mouseOffsetX;
|
||||
brOffset.y += mouseOffsetY;
|
||||
blOffset.x += mouseOffsetX;
|
||||
blOffset.y += mouseOffsetY;
|
||||
}
|
||||
|
||||
const tl = gsap.timeline();
|
||||
const corners = [tlc, trc, brc, blc];
|
||||
const offsets = [tlOffset, trOffset, brOffset, blOffset];
|
||||
|
||||
corners.forEach((corner, index) => {
|
||||
tl.to(
|
||||
corner as HTMLElement,
|
||||
{
|
||||
x: offsets[index].x,
|
||||
y: offsets[index].y,
|
||||
duration: 0.2,
|
||||
ease: 'power2.out'
|
||||
},
|
||||
0
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
isAnimatingToTarget = true;
|
||||
updateCorners();
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimatingToTarget = false;
|
||||
}, 1);
|
||||
|
||||
let moveThrottle: number | null = null;
|
||||
const targetMove = (ev: Event) => {
|
||||
if (moveThrottle || isAnimatingToTarget) return;
|
||||
moveThrottle = requestAnimationFrame(() => {
|
||||
const mouseEvent = ev as MouseEvent;
|
||||
updateCorners(mouseEvent.clientX, mouseEvent.clientY);
|
||||
moveThrottle = null;
|
||||
});
|
||||
};
|
||||
|
||||
const leaveHandler = () => {
|
||||
activeTarget = null;
|
||||
isAnimatingToTarget = false;
|
||||
|
||||
if (cornersRef.value) {
|
||||
const corners = Array.from(cornersRef.value);
|
||||
gsap.killTweensOf(corners);
|
||||
|
||||
const { cornerSize } = constants;
|
||||
const positions = [
|
||||
{ x: -cornerSize * 1.5, y: -cornerSize * 1.5 },
|
||||
{ x: cornerSize * 0.5, y: -cornerSize * 1.5 },
|
||||
{ x: cornerSize * 0.5, y: cornerSize * 0.5 },
|
||||
{ x: -cornerSize * 1.5, y: cornerSize * 0.5 }
|
||||
];
|
||||
|
||||
const tl = gsap.timeline();
|
||||
corners.forEach((corner, index) => {
|
||||
tl.to(
|
||||
corner as HTMLElement,
|
||||
{
|
||||
x: positions[index].x,
|
||||
y: positions[index].y,
|
||||
duration: 0.3,
|
||||
ease: 'power3.out'
|
||||
},
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resumeTimeout = setTimeout(() => {
|
||||
if (!activeTarget && cursorRef.value && spinTl.value) {
|
||||
const currentRotation = gsap.getProperty(cursorRef.value, 'rotation') as number;
|
||||
const normalizedRotation = currentRotation % 360;
|
||||
|
||||
spinTl.value.kill();
|
||||
spinTl.value = gsap
|
||||
.timeline({ repeat: -1 })
|
||||
.to(cursorRef.value, { rotation: '+=360', duration: props.spinDuration, ease: 'none' });
|
||||
|
||||
gsap.to(cursorRef.value, {
|
||||
rotation: normalizedRotation + 360,
|
||||
duration: props.spinDuration * (1 - normalizedRotation / 360),
|
||||
ease: 'none',
|
||||
onComplete: () => {
|
||||
spinTl.value?.restart();
|
||||
}
|
||||
});
|
||||
}
|
||||
resumeTimeout = null;
|
||||
}, 50);
|
||||
|
||||
cleanupTarget(target);
|
||||
};
|
||||
|
||||
currentTargetMove = targetMove;
|
||||
currentLeaveHandler = leaveHandler;
|
||||
|
||||
target.addEventListener('mousemove', targetMove);
|
||||
target.addEventListener('mouseleave', leaveHandler);
|
||||
};
|
||||
|
||||
window.addEventListener('mouseover', enterHandler, { passive: true });
|
||||
|
||||
cleanupAnimation = () => {
|
||||
window.removeEventListener('mousemove', moveHandler);
|
||||
window.removeEventListener('mouseover', enterHandler);
|
||||
|
||||
if (activeTarget) {
|
||||
cleanupTarget(activeTarget);
|
||||
}
|
||||
|
||||
spinTl.value?.kill();
|
||||
document.body.style.cursor = originalCursor;
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupAnimation();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.targetSelector, props.spinDuration, moveCursor, constants, props.hideDefaultCursor],
|
||||
() => {
|
||||
cleanupAnimation();
|
||||
setupAnimation();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.spinDuration,
|
||||
() => {
|
||||
if (!cursorRef.value || !spinTl.value) return;
|
||||
|
||||
if (spinTl.value.isActive()) {
|
||||
spinTl.value.kill();
|
||||
spinTl.value = gsap
|
||||
.timeline({ repeat: -1 })
|
||||
.to(cursorRef.value, { rotation: '+=360', duration: props.spinDuration, ease: 'none' });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="cursorRef"
|
||||
class="top-0 left-0 z-[9999] fixed w-0 h-0 -translate-x-1/2 -translate-y-1/2 pointer-events-none mix-blend-difference transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
>
|
||||
<div
|
||||
class="top-1/2 left-1/2 absolute bg-white rounded-full w-1 h-1 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
/>
|
||||
<div
|
||||
class="top-1/2 left-1/2 absolute border-[3px] border-white border-r-0 border-b-0 w-3 h-3 -translate-x-[150%] -translate-y-[150%] target-cursor-corner transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
/>
|
||||
<div
|
||||
class="top-1/2 left-1/2 absolute border-[3px] border-white border-b-0 border-l-0 w-3 h-3 -translate-y-[150%] translate-x-1/2 target-cursor-corner transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
/>
|
||||
<div
|
||||
class="top-1/2 left-1/2 absolute border-[3px] border-white border-t-0 border-l-0 w-3 h-3 translate-x-1/2 translate-y-1/2 target-cursor-corner transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
/>
|
||||
<div
|
||||
class="top-1/2 left-1/2 absolute border-[3px] border-white border-t-0 border-r-0 w-3 h-3 -translate-x-[150%] translate-y-1/2 target-cursor-corner transform"
|
||||
:style="{ willChange: 'transform' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
103
src/demo/Animations/TargetCursorDemo.vue
Normal file
103
src/demo/Animations/TargetCursorDemo.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<TabbedLayout>
|
||||
<template #preview>
|
||||
<div class="flex-col h-[500px] overflow-hidden demo-container">
|
||||
<p class="mb-6 font-black text-[#aeffc5]/20 text-[clamp(2rem,6vw,3rem)]">Hover Below.</p>
|
||||
|
||||
<div class="gap-4 grid grid-cols-3 mb-2">
|
||||
<div>
|
||||
<p
|
||||
class="px-6 py-2 border border-[#aeffc5] border-dashed rounded-[15px] font-black text-[#aeffc5] text-[2rem] text-center cursor-target"
|
||||
>
|
||||
THIS
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="px-6 py-2 border border-[#aeffc5] border-dashed rounded-[15px] font-black text-[#aeffc5] text-[2rem] text-center cursor-target"
|
||||
>
|
||||
FEELS
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="px-6 py-2 border border-[#aeffc5] border-dashed rounded-[15px] font-black text-[#aeffc5] text-[2rem] text-center cursor-target"
|
||||
>
|
||||
QUITE
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<p
|
||||
class="px-6 py-2 border border-[#aeffc5] border-dashed rounded-[15px] font-black text-[#aeffc5] text-[2rem] text-center cursor-target"
|
||||
>
|
||||
SNAPPY!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Customize>
|
||||
<PreviewSlider title="Spin Duration" :min="0.5" :max="5" :step="0.1" value-unit="s" v-model="spinDuration" />
|
||||
|
||||
<PreviewSwitch title="Hide Default Cursor" v-model="hideDefaultCursor" />
|
||||
</Customize>
|
||||
|
||||
<PropTable :data="propData" />
|
||||
<Dependencies :dependency-list="['gsap']" />
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<CodeExample :code-object="targetCursor" />
|
||||
</template>
|
||||
|
||||
<template #cli>
|
||||
<CliInstallation :command="targetCursor.cli" />
|
||||
</template>
|
||||
</TabbedLayout>
|
||||
|
||||
<TargetCursor :spin-duration="spinDuration" :hide-default-cursor="hideDefaultCursor" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||
import CodeExample from '../../components/code/CodeExample.vue';
|
||||
import Dependencies from '../../components/code/Dependencies.vue';
|
||||
import Customize from '../../components/common/Customize.vue';
|
||||
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||
import PropTable from '../../components/common/PropTable.vue';
|
||||
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||
import { targetCursor } from '../../constants/code/Animations/targetCursorCode';
|
||||
import TargetCursor from '../../content/Animations/TargetCursor/TargetCursor.vue';
|
||||
|
||||
const spinDuration = ref(2);
|
||||
const hideDefaultCursor = ref(true);
|
||||
|
||||
const propData = [
|
||||
{
|
||||
name: 'targetSelector',
|
||||
type: 'string',
|
||||
default: '".cursor-target"',
|
||||
description: 'CSS selector for elements that should trigger the cursor targeting effect'
|
||||
},
|
||||
{
|
||||
name: 'spinDuration',
|
||||
type: 'number',
|
||||
default: '2',
|
||||
description: "Duration in seconds for the cursor's spinning animation when not targeting"
|
||||
},
|
||||
{
|
||||
name: 'hideDefaultCursor',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Whether to hide the default browser cursor when the component is active'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-container {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user