mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39: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',
|
'Meta Balls',
|
||||||
'Image Trail',
|
'Image Trail',
|
||||||
'Shape Blur',
|
'Shape Blur',
|
||||||
|
'Target Cursor',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const animations = {
|
|||||||
'meta-balls': () => import('../demo/Animations/MetaBallsDemo.vue'),
|
'meta-balls': () => import('../demo/Animations/MetaBallsDemo.vue'),
|
||||||
'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'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const textAnimations = {
|
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