Files
vue-bits/public/r/TargetCursor.json
2026-01-21 16:08:55 +05:30

1 line
11 KiB
JSON

{"name":"TargetCursor","title":"TargetCursor","description":"A cursor follow animation with 4 corners that lock onto targets.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { gsap } from 'gsap';\nimport { onMounted, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';\n\ninterface TargetCursorProps {\n targetSelector?: string;\n spinDuration?: number;\n hideDefaultCursor?: boolean;\n}\n\nconst props = withDefaults(defineProps<TargetCursorProps>(), {\n targetSelector: '.cursor-target',\n spinDuration: 2,\n hideDefaultCursor: true\n});\n\nconst cursorRef = useTemplateRef('cursorRef');\nconst cornersRef = ref<NodeListOf<HTMLDivElement> | null>(null);\nconst spinTl = ref<gsap.core.Timeline | null>(null);\n\nconst constants = {\n borderWidth: 3,\n cornerSize: 12,\n parallaxStrength: 0.00005\n};\n\nconst moveCursor = (x: number, y: number) => {\n if (!cursorRef.value) return;\n\n gsap.to(cursorRef.value, {\n x,\n y,\n duration: 0.1,\n ease: 'power3.out'\n });\n};\n\nlet cleanupAnimation: () => void = () => {};\n\nconst setupAnimation = () => {\n if (!cursorRef.value) return;\n\n const originalCursor = document.body.style.cursor;\n if (props.hideDefaultCursor) {\n document.body.style.cursor = 'none';\n }\n\n const cursor = cursorRef.value;\n cornersRef.value = cursor.querySelectorAll<HTMLDivElement>('.target-cursor-corner');\n\n let activeTarget: Element | null = null;\n let currentTargetMove: ((ev: Event) => void) | null = null;\n let currentLeaveHandler: (() => void) | null = null;\n let isAnimatingToTarget = false;\n let resumeTimeout: ReturnType<typeof setTimeout> | null = null;\n\n const cleanupTarget = (target: Element) => {\n if (currentTargetMove) {\n target.removeEventListener('mousemove', currentTargetMove);\n }\n if (currentLeaveHandler) {\n target.removeEventListener('mouseleave', currentLeaveHandler);\n }\n currentTargetMove = null;\n currentLeaveHandler = null;\n };\n\n gsap.set(cursor, {\n xPercent: -50,\n yPercent: -50,\n x: window.innerWidth / 2,\n y: window.innerHeight / 2,\n opacity: 1,\n display: 'block'\n });\n\n const createSpinTimeline = () => {\n if (spinTl.value) {\n spinTl.value.kill();\n }\n spinTl.value = gsap.timeline({ repeat: -1 }).to(cursor, {\n rotation: '+=360',\n duration: props.spinDuration,\n ease: 'none'\n });\n };\n\n createSpinTimeline();\n\n const moveHandler = (e: MouseEvent) => moveCursor(e.clientX, e.clientY);\n window.addEventListener('mousemove', moveHandler);\n\n const enterHandler = (e: MouseEvent) => {\n const directTarget = e.target as Element;\n\n const allTargets: Element[] = [];\n let current = directTarget;\n while (current && current !== document.body) {\n if (current.matches(props.targetSelector)) {\n allTargets.push(current);\n }\n current = current.parentElement!;\n }\n\n const target = allTargets[0] || null;\n if (!target || !cursorRef.value || !cornersRef.value) return;\n\n if (activeTarget === target) return;\n\n if (activeTarget) {\n cleanupTarget(activeTarget);\n }\n\n if (resumeTimeout) {\n clearTimeout(resumeTimeout);\n resumeTimeout = null;\n }\n\n activeTarget = target;\n const corners = Array.from(cornersRef.value);\n corners.forEach(corner => {\n gsap.killTweensOf(corner);\n });\n gsap.killTweensOf(cursorRef.value, 'rotation');\n spinTl.value?.pause();\n\n gsap.set(cursorRef.value, { rotation: 0 });\n\n const updateCorners = (mouseX?: number, mouseY?: number) => {\n const rect = target.getBoundingClientRect();\n const cursorRect = cursorRef.value!.getBoundingClientRect();\n\n const cursorCenterX = cursorRect.left + cursorRect.width / 2;\n const cursorCenterY = cursorRect.top + cursorRect.height / 2;\n\n const [tlc, trc, brc, blc] = Array.from(cornersRef.value!);\n\n const { borderWidth, cornerSize, parallaxStrength } = constants;\n\n const tlOffset = {\n x: rect.left - cursorCenterX - borderWidth,\n y: rect.top - cursorCenterY - borderWidth\n };\n const trOffset = {\n x: rect.right - cursorCenterX + borderWidth - cornerSize,\n y: rect.top - cursorCenterY - borderWidth\n };\n const brOffset = {\n x: rect.right - cursorCenterX + borderWidth - cornerSize,\n y: rect.bottom - cursorCenterY + borderWidth - cornerSize\n };\n const blOffset = {\n x: rect.left - cursorCenterX - borderWidth,\n y: rect.bottom - cursorCenterY + borderWidth - cornerSize\n };\n\n if (mouseX !== undefined && mouseY !== undefined) {\n const targetCenterX = rect.left + rect.width / 2;\n const targetCenterY = rect.top + rect.height / 2;\n const mouseOffsetX = (mouseX - targetCenterX) * parallaxStrength;\n const mouseOffsetY = (mouseY - targetCenterY) * parallaxStrength;\n\n tlOffset.x += mouseOffsetX;\n tlOffset.y += mouseOffsetY;\n trOffset.x += mouseOffsetX;\n trOffset.y += mouseOffsetY;\n brOffset.x += mouseOffsetX;\n brOffset.y += mouseOffsetY;\n blOffset.x += mouseOffsetX;\n blOffset.y += mouseOffsetY;\n }\n\n const tl = gsap.timeline();\n const corners = [tlc, trc, brc, blc];\n const offsets = [tlOffset, trOffset, brOffset, blOffset];\n\n corners.forEach((corner, index) => {\n const offset = offsets[index];\n if (!offset) return;\n tl.to(\n corner as HTMLElement,\n {\n x: offset.x,\n y: offset.y,\n duration: 0.2,\n ease: 'power2.out'\n },\n 0\n );\n });\n };\n\n isAnimatingToTarget = true;\n updateCorners();\n\n setTimeout(() => {\n isAnimatingToTarget = false;\n }, 1);\n\n let moveThrottle: number | null = null;\n const targetMove = (ev: Event) => {\n if (moveThrottle || isAnimatingToTarget) return;\n moveThrottle = requestAnimationFrame(() => {\n const mouseEvent = ev as MouseEvent;\n updateCorners(mouseEvent.clientX, mouseEvent.clientY);\n moveThrottle = null;\n });\n };\n\n const leaveHandler = () => {\n activeTarget = null;\n isAnimatingToTarget = false;\n\n if (cornersRef.value) {\n const corners = Array.from(cornersRef.value);\n gsap.killTweensOf(corners);\n\n const { cornerSize } = constants;\n const positions = [\n { x: -cornerSize * 1.5, y: -cornerSize * 1.5 },\n { x: cornerSize * 0.5, y: -cornerSize * 1.5 },\n { x: cornerSize * 0.5, y: cornerSize * 0.5 },\n { x: -cornerSize * 1.5, y: cornerSize * 0.5 }\n ];\n\n const tl = gsap.timeline();\n corners.forEach((corner, index) => {\n const pos = positions[index];\n if (!pos) return;\n tl.to(\n corner as HTMLElement,\n {\n x: pos.x,\n y: pos.y,\n duration: 0.3,\n ease: 'power3.out'\n },\n 0\n );\n });\n }\n\n resumeTimeout = setTimeout(() => {\n if (!activeTarget && cursorRef.value && spinTl.value) {\n const currentRotation = gsap.getProperty(cursorRef.value, 'rotation') as number;\n const normalizedRotation = currentRotation % 360;\n\n spinTl.value.kill();\n spinTl.value = gsap.timeline({ repeat: -1 }).to(cursorRef.value, {\n rotation: '+=360',\n duration: props.spinDuration,\n ease: 'none'\n });\n\n gsap.to(cursorRef.value, {\n rotation: normalizedRotation + 360,\n duration: props.spinDuration * (1 - normalizedRotation / 360),\n ease: 'none',\n onComplete: () => {\n spinTl.value?.restart();\n }\n });\n }\n resumeTimeout = null;\n }, 50);\n\n cleanupTarget(target);\n };\n\n currentTargetMove = targetMove;\n currentLeaveHandler = leaveHandler;\n\n target.addEventListener('mousemove', targetMove);\n target.addEventListener('mouseleave', leaveHandler);\n };\n\n window.addEventListener('mouseover', enterHandler, { passive: true });\n\n cleanupAnimation = () => {\n window.removeEventListener('mousemove', moveHandler);\n window.removeEventListener('mouseover', enterHandler);\n\n if (activeTarget) {\n cleanupTarget(activeTarget);\n }\n\n if (resumeTimeout) {\n clearTimeout(resumeTimeout);\n resumeTimeout = null;\n }\n\n spinTl.value?.kill();\n spinTl.value = null;\n\n if (cursorRef.value) {\n gsap.killTweensOf(cursorRef.value);\n }\n if (cornersRef.value) {\n gsap.killTweensOf(Array.from(cornersRef.value));\n }\n\n if (cursorRef.value) {\n gsap.set(cursorRef.value, {\n x: 0,\n y: 0,\n rotation: 0,\n opacity: 0,\n display: 'none'\n });\n }\n\n document.body.style.cursor = originalCursor;\n activeTarget = null;\n };\n};\n\nonMounted(() => {\n setupAnimation();\n});\n\nonBeforeUnmount(() => {\n cleanupAnimation();\n});\n\nwatch(\n () => [props.targetSelector, props.spinDuration, props.hideDefaultCursor],\n () => {\n cleanupAnimation();\n setupAnimation();\n }\n);\n\nwatch(\n () => props.spinDuration,\n () => {\n if (!cursorRef.value || !spinTl.value) return;\n\n if (spinTl.value.isActive()) {\n spinTl.value.kill();\n spinTl.value = gsap.timeline({ repeat: -1 }).to(cursorRef.value, {\n rotation: '+=360',\n duration: props.spinDuration,\n ease: 'none'\n });\n }\n },\n { immediate: true }\n);\n</script>\n\n<template>\n <div\n ref=\"cursorRef\"\n 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 opacity-0\"\n :style=\"{ willChange: 'transform' }\"\n >\n <div\n 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\"\n :style=\"{ willChange: 'transform' }\"\n />\n <div\n 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\"\n :style=\"{ willChange: 'transform' }\"\n />\n <div\n 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\"\n :style=\"{ willChange: 'transform' }\"\n />\n <div\n 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\"\n :style=\"{ willChange: 'transform' }\"\n />\n <div\n 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\"\n :style=\"{ willChange: 'transform' }\"\n />\n </div>\n</template>\n","path":"TargetCursor/TargetCursor.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Animations"]}