Added <TargetCursor /> Animation

This commit is contained in:
Utkarsh-Singhal-26
2025-07-18 18:18:31 +05:30
parent 94c4cdd704
commit 3b90ac916e
5 changed files with 466 additions and 0 deletions

View File

@@ -47,6 +47,7 @@ export const CATEGORIES = [
'Blob Cursor',
'Meta Balls',
'Image Trail',
'Target Cursor',
]
},
{

View File

@@ -15,6 +15,7 @@ const animations = {
'blob-cursor': () => import('../demo/Animations/BlobCursorDemo.vue'),
'meta-balls': () => import('../demo/Animations/MetaBallsDemo.vue'),
'image-trail': () => import('../demo/Animations/ImageTrailDemo.vue'),
'target-cursor': () => import('../demo/Animations/TargetCursorDemo.vue'),
};
const textAnimations = {

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

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

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