Merge pull request #137 from Utkarsh-Singhal-26/refact/text-animations

[ REVAMP ] : Text Animations
This commit is contained in:
David
2026-02-24 23:12:16 +02:00
committed by GitHub
25 changed files with 1206 additions and 887 deletions

36
package-lock.json generated
View File

@@ -22,7 +22,7 @@
"lucide-vue-next": "^0.548.0",
"mathjs": "^14.6.0",
"matter-js": "^0.20.0",
"motion-v": "^1.5.0",
"motion-v": "^1.10.2",
"ogl": "^1.0.11",
"postprocessing": "^6.37.6",
"primeicons": "^7.0.0",
@@ -8164,13 +8164,13 @@
}
},
"node_modules/framer-motion": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
"integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz",
"integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.12",
"motion-utils": "^12.23.6",
"motion-dom": "^12.29.2",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -9984,29 +9984,29 @@
"license": "MIT"
},
"node_modules/motion-dom": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
"integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz",
"integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/motion-v": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/motion-v/-/motion-v-1.7.1.tgz",
"integrity": "sha512-B22fYcHGx05moUtoIH0ZP/JzeacGOHzLkLmMTKU9tRB+uVMSfgqiXVzZb602qiG1ap8W7TZ+5RD5R3MmODu9oA==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/motion-v/-/motion-v-1.10.2.tgz",
"integrity": "sha512-K+Zus21KKgZP4CBY7CvU/B7UZCV9sZTHG0FgsAfGHlbZi+u8EolmZ2kvJe5zOG0RzCgdiVCobHBt54qch9rweg==",
"license": "MIT",
"dependencies": {
"framer-motion": "12.23.12",
"framer-motion": "^12.25.0",
"hey-listen": "^1.0.8",
"motion-dom": "12.23.12"
"motion-dom": "^12.23.23"
},
"peerDependencies": {
"@vueuse/core": ">=10.0.0",

View File

@@ -31,7 +31,7 @@
"lucide-vue-next": "^0.548.0",
"mathjs": "^14.6.0",
"matter-js": "^0.20.0",
"motion-v": "^1.5.0",
"motion-v": "^1.10.2",
"ogl": "^1.0.11",
"postprocessing": "^6.37.6",
"primeicons": "^7.0.0",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"name":"CircularText","title":"CircularText","description":"Layouts characters around a circle with optional rotation animation.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { computed, ref, watchEffect, onUnmounted } from 'vue';\nimport { Motion } from 'motion-v';\n\ninterface CircularTextProps {\n text: string;\n spinDuration?: number;\n onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers';\n className?: string;\n}\n\nconst props = withDefaults(defineProps<CircularTextProps>(), {\n text: '',\n spinDuration: 20,\n onHover: 'speedUp',\n className: ''\n});\n\nconst letters = computed(() => Array.from(props.text));\nconst isHovered = ref(false);\n\nconst currentRotation = ref(0);\nconst animationId = ref<number | null>(null);\nconst lastTime = ref<number>(Date.now());\nconst rotationSpeed = ref<number>(0);\n\nconst getCurrentSpeed = () => {\n if (isHovered.value && props.onHover === 'pause') return 0;\n\n const baseDuration = props.spinDuration;\n const baseSpeed = 360 / baseDuration;\n\n if (!isHovered.value) return baseSpeed;\n\n switch (props.onHover) {\n case 'slowDown':\n return baseSpeed / 2;\n case 'speedUp':\n return baseSpeed * 4;\n case 'goBonkers':\n return baseSpeed * 20;\n default:\n return baseSpeed;\n }\n};\n\nconst getCurrentScale = () => {\n return isHovered.value && props.onHover === 'goBonkers' ? 0.8 : 1;\n};\n\nconst animate = () => {\n const now = Date.now();\n const deltaTime = (now - lastTime.value) / 1000;\n lastTime.value = now;\n\n const targetSpeed = getCurrentSpeed();\n\n const speedDiff = targetSpeed - rotationSpeed.value;\n const smoothingFactor = Math.min(1, deltaTime * 5);\n rotationSpeed.value += speedDiff * smoothingFactor;\n\n currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;\n\n animationId.value = requestAnimationFrame(animate);\n};\n\nconst startAnimation = () => {\n if (animationId.value) {\n cancelAnimationFrame(animationId.value);\n }\n lastTime.value = Date.now();\n rotationSpeed.value = getCurrentSpeed();\n animate();\n};\n\nwatchEffect(() => {\n startAnimation();\n});\n\nstartAnimation();\n\nonUnmounted(() => {\n if (animationId.value) {\n cancelAnimationFrame(animationId.value);\n }\n});\n\nconst handleHoverStart = () => {\n isHovered.value = true;\n};\n\nconst handleHoverEnd = () => {\n isHovered.value = false;\n};\n\nconst getLetterTransform = (index: number) => {\n const rotationDeg = (360 / letters.value.length) * index;\n const factor = Math.PI / letters.value.length;\n const x = factor * index;\n const y = factor * index;\n return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;\n};\n</script>\n\n<template>\n <Motion\n :animate=\"{\n rotate: currentRotation,\n scale: getCurrentScale()\n }\"\n :transition=\"{\n rotate: {\n duration: 0\n },\n scale: {\n type: 'spring',\n damping: 20,\n stiffness: 300\n }\n }\"\n :class=\"`m-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center ${props.className}`\"\n @mouseenter=\"handleHoverStart\"\n @mouseleave=\"handleHoverEnd\"\n >\n <span\n v-for=\"(letter, i) in letters\"\n :key=\"i\"\n class=\"absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]\"\n :style=\"{\n transform: getLetterTransform(i),\n WebkitTransform: getLetterTransform(i)\n }\"\n >\n {{ letter }}\n </span>\n </Motion>\n</template>\n","path":"CircularText/CircularText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"motion-v","version":"^1.5.0"}],"devDependencies":[],"categories":["TextAnimations"]}
{"name":"CircularText","title":"CircularText","description":"Layouts characters around a circle with optional rotation animation.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { animate, Motion, MotionValue, useMotionValue } from 'motion-v';\nimport { computed, onMounted, watch } from 'vue';\n\ninterface CircularTextProps {\n text: string;\n spinDuration?: number;\n onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers';\n className?: string;\n}\n\nconst props = withDefaults(defineProps<CircularTextProps>(), {\n spinDuration: 20,\n onHover: 'speedUp',\n className: ''\n});\n\nconst letters = computed(() => Array.from(props.text));\nconst rotation: MotionValue<number> = useMotionValue(0);\n\nlet currentAnimation: ReturnType<typeof animate> | null = null;\n\nconst startRotation = (duration: number) => {\n currentAnimation?.stop();\n const start = rotation.get();\n\n currentAnimation = animate(rotation, start + 360, {\n duration,\n ease: 'linear',\n repeat: Infinity\n });\n};\n\nonMounted(() => {\n startRotation(props.spinDuration);\n});\n\nwatch(\n () => [props.spinDuration, props.text],\n () => {\n startRotation(props.spinDuration);\n }\n);\n\nconst handleHoverStart = () => {\n if (!props.onHover) return;\n\n switch (props.onHover) {\n case 'slowDown':\n startRotation(props.spinDuration * 2);\n break;\n case 'speedUp':\n startRotation(props.spinDuration / 4);\n break;\n case 'pause':\n currentAnimation?.stop();\n break;\n case 'goBonkers':\n startRotation(props.spinDuration / 20);\n break;\n }\n};\n\nconst handleHoverEnd = () => {\n startRotation(props.spinDuration);\n};\n\nconst getLetterTransform = (index: number) => {\n const rotationDeg = (360 / letters.value.length) * index;\n const factor = Math.PI / letters.value.length;\n const x = factor * index;\n const y = factor * index;\n return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;\n};\n</script>\n\n<template>\n <Motion\n tag=\"div\"\n :class=\"[\n 'm-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center',\n className\n ]\"\n :style=\"{\n rotate: rotation\n }\"\n :initial=\"{\n rotate: 0\n }\"\n @mouseenter=\"handleHoverStart\"\n @mouseleave=\"handleHoverEnd\"\n >\n <span\n v-for=\"(letter, i) in letters\"\n :key=\"i\"\n class=\"inline-block absolute inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]\"\n :style=\"{\n transform: getLetterTransform(i),\n WebkitTransform: getLetterTransform(i)\n }\"\n >\n {{ letter }}\n </span>\n </Motion>\n</template>\n","path":"CircularText/CircularText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"motion-v","version":"^1.10.2"}],"devDependencies":[],"categories":["TextAnimations"]}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"name":"GradientText","title":"GradientText","description":"Animated gradient sweep across live text with speed and color control.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { computed } from 'vue';\n\ninterface GradientTextProps {\n text: string;\n className?: string;\n colors?: string[];\n animationSpeed?: number;\n showBorder?: boolean;\n}\n\nconst props = withDefaults(defineProps<GradientTextProps>(), {\n text: '',\n className: '',\n colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],\n animationSpeed: 8,\n showBorder: false\n});\n\nconst gradientStyle = computed(() => ({\n backgroundImage: `linear-gradient(to right, ${props.colors.join(', ')})`,\n animationDuration: `${props.animationSpeed}s`,\n backgroundSize: '300% 100%',\n '--animation-duration': `${props.animationSpeed}s`\n}));\n\nconst borderStyle = computed(() => ({\n ...gradientStyle.value\n}));\n\nconst textStyle = computed(() => ({\n ...gradientStyle.value,\n backgroundClip: 'text',\n WebkitBackgroundClip: 'text'\n}));\n</script>\n\n<template>\n <div\n :class=\"`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${className}`\"\n >\n <div\n v-if=\"showBorder\"\n class=\"absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient\"\n :style=\"borderStyle\"\n >\n <div\n class=\"absolute inset-0 bg-black rounded-[1.25rem] z-[-1]\"\n style=\"width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)\"\n />\n </div>\n\n <div class=\"inline-block relative z-2 text-transparent bg-cover animate-gradient\" :style=\"textStyle\">\n {{ text }}\n </div>\n </div>\n</template>\n\n<style scoped>\n@keyframes gradient {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n}\n\n.animate-gradient {\n animation: gradient var(--animation-duration, 8s) linear infinite;\n}\n</style>\n","path":"GradientText/GradientText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["TextAnimations"]}
{"name":"GradientText","title":"GradientText","description":"Animated gradient sweep across live text with speed and color control.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';\nimport { computed, ref, useSlots } from 'vue';\n\ninterface GradientTextProps {\n className?: string;\n colors?: string[];\n animationSpeed?: number;\n showBorder?: boolean;\n direction?: 'horizontal' | 'vertical' | 'diagonal';\n pauseOnHover?: boolean;\n yoyo?: boolean;\n}\n\nconst props = withDefaults(defineProps<GradientTextProps>(), {\n className: '',\n colors: () => ['#27FF64', '#27FF64', '#A0FFBC'],\n animationSpeed: 8,\n showBorder: false,\n direction: 'horizontal',\n pauseOnHover: false,\n yoyo: true\n});\n\nconst slots = useSlots();\nconst text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));\n\nconst isPaused = ref(false);\nconst progress = useMotionValue(0);\nconst elapsedRef = ref(0);\nconst lastTimeRef = ref<number | null>(null);\n\nconst animationDuration = props.animationSpeed * 1000;\n\nuseAnimationFrame(time => {\n if (isPaused.value) {\n lastTimeRef.value = null;\n return;\n }\n\n if (lastTimeRef.value === null) {\n lastTimeRef.value = time;\n return;\n }\n\n const deltaTime = time - lastTimeRef.value;\n lastTimeRef.value = time;\n elapsedRef.value += deltaTime;\n\n if (props.yoyo) {\n const fullCycle = animationDuration * 2;\n const cycleTime = elapsedRef.value % fullCycle;\n\n if (cycleTime < animationDuration) {\n progress.set((cycleTime / animationDuration) * 100);\n } else {\n progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);\n }\n } else {\n // Continuously increase position for seamless looping\n progress.set((elapsedRef.value / animationDuration) * 100);\n }\n});\n\nconst backgroundPosition = useTransform(progress, p => {\n if (props.direction === 'horizontal') {\n return `${p}% 50%`;\n } else if (props.direction === 'vertical') {\n return `50% ${p}%`;\n } else {\n // For diagonal, move only horizontally to avoid interference patterns\n return `${p}% 50%`;\n }\n});\n\nconst handleMouseEnter = () => {\n if (props.pauseOnHover) isPaused.value = true;\n};\n\nconst handleMouseLeave = () => {\n if (props.pauseOnHover) isPaused.value = false;\n};\n\nconst gradientAngle = computed(() =>\n props.direction === 'horizontal' ? 'to right' : props.direction === 'vertical' ? 'to bottom' : 'to bottom right'\n);\n\n// Duplicate first color at the end for seamless looping\nconst gradientColors = computed(() => [...props.colors, props.colors[0]].join(', '));\n\nconst gradientStyle = computed(() => ({\n backgroundImage: `linear-gradient(${gradientAngle.value}, ${gradientColors.value})`,\n backgroundSize:\n props.direction === 'horizontal' ? '300% 100%' : props.direction === 'vertical' ? '100% 300%' : '300% 300%',\n backgroundRepeat: 'repeat'\n}));\n</script>\n\n<template>\n <Motion\n tag=\"div\"\n :class=\"[\n 'relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer',\n className,\n showBorder && 'py-1 px-2'\n ]\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n >\n <Motion\n tag=\"div\"\n v-if=\"showBorder\"\n class=\"z-0 absolute inset-0 rounded-[1.25rem] pointer-events-none\"\n :style=\"{ ...gradientStyle, backgroundPosition }\"\n >\n <div\n class=\"z-[-1] absolute bg-black rounded-[1.25rem]\"\n :style=\"{\n width: 'calc(100% - 2px)',\n height: 'calc(100% - 2px)',\n left: '50%',\n top: '50%',\n transform: 'translate(-50%, -50%)'\n }\"\n />\n </Motion>\n\n <Motion\n tag=\"div\"\n class=\"inline-block z-2 relative bg-clip-text text-transparent\"\n :style=\"{\n ...gradientStyle,\n backgroundPosition,\n WebkitBackgroundClip: 'text'\n }\"\n >\n {{ text }}\n </Motion>\n </Motion>\n</template>\n","path":"GradientText/GradientText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"motion-v","version":"^1.10.2"}],"devDependencies":[],"categories":["TextAnimations"]}

View File

@@ -1 +1 @@
{"name":"ShinyText","title":"ShinyText","description":"Metallic sheen sweeps across text producing a reflective highlight.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { computed } from 'vue';\n\ninterface ShinyTextProps {\n text: string;\n disabled?: boolean;\n speed?: number;\n className?: string;\n}\n\nconst props = withDefaults(defineProps<ShinyTextProps>(), {\n text: '',\n disabled: false,\n speed: 5,\n className: ''\n});\n\nconst animationDuration = computed(() => `${props.speed}s`);\n</script>\n\n<template>\n <div\n :class=\"`text-[#b5b5b5a4] bg-clip-text inline-block ${!props.disabled ? 'animate-shine' : ''} ${props.className}`\"\n :style=\"{\n backgroundImage:\n 'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',\n backgroundSize: '200% 100%',\n WebkitBackgroundClip: 'text',\n animationDuration: animationDuration\n }\"\n >\n {{ props.text }}\n </div>\n</template>\n\n<style scoped>\n@keyframes shine {\n 0% {\n background-position: 100%;\n }\n 100% {\n background-position: -100%;\n }\n}\n\n.animate-shine {\n animation: shine 5s linear infinite;\n}\n</style>\n","path":"ShinyText/ShinyText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["TextAnimations"]}
{"name":"ShinyText","title":"ShinyText","description":"Metallic sheen sweeps across text producing a reflective highlight.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';\nimport { computed, ref, watch } from 'vue';\n\ninterface ShinyTextProps {\n text: string;\n disabled?: boolean;\n speed?: number;\n className?: string;\n color?: string;\n shineColor?: string;\n spread?: number;\n yoyo?: boolean;\n pauseOnHover?: boolean;\n direction?: 'left' | 'right';\n delay?: number;\n}\n\nconst props = withDefaults(defineProps<ShinyTextProps>(), {\n disabled: false,\n speed: 2,\n className: '',\n color: '#b5b5b5',\n shineColor: '#ffffff',\n spread: 120,\n yoyo: false,\n pauseOnHover: false,\n direction: 'left',\n delay: 0\n});\n\nconst isPaused = ref(false);\nconst progress = useMotionValue(0);\nconst elapsedRef = ref(0);\nconst lastTimeRef = ref<number | null>(null);\nconst directionRef = ref(props.direction === 'left' ? 1 : -1);\n\nconst animationDuration = computed(() => props.speed * 1000);\nconst delayDuration = computed(() => props.delay * 1000);\n\nuseAnimationFrame(time => {\n if (props.disabled || isPaused.value) {\n lastTimeRef.value = null;\n return;\n }\n\n if (lastTimeRef.value === null) {\n lastTimeRef.value = time;\n return;\n }\n\n const deltaTime = time - lastTimeRef.value;\n lastTimeRef.value = time;\n\n elapsedRef.value += deltaTime;\n\n // Animation goes from 0 to 100\n if (props.yoyo) {\n const cycleDuration = animationDuration.value + delayDuration.value;\n const fullCycle = cycleDuration * 2;\n const cycleTime = elapsedRef.value % fullCycle;\n\n if (cycleTime < animationDuration.value) {\n // Forward animation: 0 -> 100\n const p = (cycleTime / animationDuration.value) * 100;\n progress.set(directionRef.value === 1 ? p : 100 - p);\n } else if (cycleTime < cycleDuration) {\n // Delay at end\n progress.set(directionRef.value === 1 ? 100 : 0);\n } else if (cycleTime < cycleDuration + animationDuration.value) {\n // Reverse animation: 100 -> 0\n const reverseTime = cycleTime - cycleDuration;\n const p = 100 - (reverseTime / animationDuration.value) * 100;\n progress.set(directionRef.value === 1 ? p : 100 - p);\n } else {\n // Delay at start\n progress.set(directionRef.value === 1 ? 0 : 100);\n }\n } else {\n const cycleDuration = animationDuration.value + delayDuration.value;\n const cycleTime = elapsedRef.value % cycleDuration;\n\n if (cycleTime < animationDuration.value) {\n // Animation phase: 0 -> 100\n const p = (cycleTime / animationDuration.value) * 100;\n progress.set(directionRef.value === 1 ? p : 100 - p);\n } else {\n // Delay phase - hold at end (shine off-screen)\n progress.set(directionRef.value === 1 ? 100 : 0);\n }\n }\n});\n\nwatch(\n () => props.direction,\n () => {\n directionRef.value = props.direction === 'left' ? 1 : -1;\n elapsedRef.value = 0;\n progress.set(0);\n },\n {\n immediate: true\n }\n);\n\nconst backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);\n\nconst handleMouseEnter = () => {\n if (props.pauseOnHover) isPaused.value = true;\n};\n\nconst handleMouseLeave = () => {\n if (props.pauseOnHover) isPaused.value = false;\n};\n\nconst gradientStyle = computed(() => ({\n backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,\n backgroundSize: '200% auto',\n WebkitBackgroundClip: 'text',\n backgroundClip: 'text',\n WebkitTextFillColor: 'transparent'\n}));\n</script>\n\n<template>\n <Motion\n tag=\"span\"\n :class=\"['inline-block', className]\"\n :style=\"{ ...gradientStyle, backgroundPosition }\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n >\n {{ text }}\n </Motion>\n</template>\n","path":"ShinyText/ShinyText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"motion-v","version":"^1.10.2"}],"devDependencies":[],"categories":["TextAnimations"]}

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,15 @@
import code from '@content/TextAnimations/BlurText/BlurText.vue?raw';
import { createCodeObject } from '@/types/code';
import code from '@content/TextAnimations/BlurText/BlurText.vue?raw';
export const blurText = createCodeObject(code, 'TextAnimations/BlurText', {
installation: `npm install motion-v`,
usage: `<template>
<BlurText
text="Isn't this so cool?!"
:delay="200"
class-name="text-2xl font-semibold text-center"
animate-by="words"
direction="top"
:threshold="0.1"
root-margin="0px"
:step-duration="0.35"
:delay="200"
class-name="text-2xl font-semibold text-center"
@animation-complete="handleAnimationComplete"
/>
</template>

View File

@@ -1,17 +1,18 @@
import code from '@content/TextAnimations/FuzzyText/FuzzyText.vue?raw';
import { createCodeObject } from '@/types/code';
import code from '@content/TextAnimations/FuzzyText/FuzzyText.vue?raw';
export const fuzzyText = createCodeObject(code, 'TextAnimations/FuzzyText', {
usage: `<template>
<FuzzyText
text="404"
:font-size="140"
font-weight="900"
:font-weight="900"
color="#fff"
:enable-hover="true"
:base-intensity="0.18"
:hover-intensity="0.5"
/>
>
404
</FuzzyText>
</template>
<script setup lang="ts">

View File

@@ -1,15 +1,18 @@
import code from '@content/TextAnimations/GradientText/GradientText.vue?raw';
import { createCodeObject } from '@/types/code';
import code from '@content/TextAnimations/GradientText/GradientText.vue?raw';
export const gradientText = createCodeObject(code, 'TextAnimations/GradientText', {
usage: `<template>
installation: `npm install motion-v`,
usage: `// For a smoother animation, the gradient should start and end with the same color
<template>
<GradientText
text="Add a splash of color!"
:colors="['#ffaa40', '#9c40ff', '#ffaa40']"
:animation-speed="8"
:colors="['#27FF64', '#27FF64', '#A0FFBC']"
:animation-speed="3"
:show-border="false"
class-name="your-custom-class"
/>
>
Add a splash of color!
</GradientText>
</template>
<script setup lang="ts">

View File

@@ -1,13 +1,20 @@
import code from '@content/TextAnimations/ShinyText/ShinyText.vue?raw';
import { createCodeObject } from '@/types/code';
import code from '@content/TextAnimations/ShinyText/ShinyText.vue?raw';
export const shinyText = createCodeObject(code, 'TextAnimations/ShinyText', {
installation: `npm install motion-v`,
usage: `<template>
<ShinyText
text="Just some shiny text!"
text="✨ Shiny Text Effect"
:speed="2"
:delay="0.5"
:disabled="false"
:speed="3"
class-name="your-custom-class"
:color="'#b5b5b5'"
:shine-color="'#ffffff'"
:spread="120"
:direction="'left'"
:yoyo="false"
:pause-on-hover="false"
/>
</template>

View File

@@ -1,81 +1,99 @@
<template>
<p ref="rootRef" :class="['blur-text', className, 'flex', 'flex-wrap']">
<p ref="rootRef" class="flex flex-wrap blur-text" :class="className">
<Motion
v-for="(segment, index) in elements"
:key="`${animationKey}-${index}`"
:key="index"
tag="span"
:initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:animate="inView ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot"
:transition="getTransition(index)"
:style="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}"
@animation-complete="() => handleAnimationComplete(index)"
@animation-complete="handleAnimationComplete(index)"
style="display: inline-block; will-change: transform, filter, opacity"
>
{{ segment === ' ' ? '\u00A0' : segment
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
{{ segment === ' ' ? '\u00A0' : segment }}
<template v-if="animateBy === 'words' && index < elements.length - 1">&nbsp;</template>
</Motion>
</p>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
import { Motion } from 'motion-v';
import { Motion, type Transition } from 'motion-v';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
type AnimateBy = 'words' | 'letters';
type Direction = 'top' | 'bottom';
type AnimationSnapshot = Record<string, string | number>;
interface BlurTextProps {
type BlurTextProps = {
text?: string;
delay?: number;
className?: string;
animateBy?: AnimateBy;
direction?: Direction;
animateBy?: 'words' | 'letters';
direction?: 'top' | 'bottom';
threshold?: number;
rootMargin?: string;
animationFrom?: AnimationSnapshot;
animationTo?: AnimationSnapshot[];
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number;
onAnimationComplete?: () => void;
stepDuration?: number;
}
};
const buildKeyframes = (
from: Record<string, string | number>,
steps: Array<Record<string, string | number>>
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
const keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])];
});
return keyframes;
};
const props = withDefaults(defineProps<BlurTextProps>(), {
text: '',
delay: 200,
className: '',
animateBy: 'words' as AnimateBy,
direction: 'top' as Direction,
animateBy: 'words',
direction: 'top',
threshold: 0.1,
rootMargin: '0px',
easing: (t: number) => t,
stepDuration: 0.35
});
const buildKeyframes = (
from: AnimationSnapshot,
steps: AnimationSnapshot[]
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(step => Object.keys(step))]);
const inView = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const keyframes: Record<string, Array<string | number>> = {};
onMounted(() => {
if (!rootRef.value) return;
for (const key of keys) {
keyframes[key] = [from[key], ...steps.map(step => step[key])];
}
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
return keyframes;
};
observer.observe(rootRef.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const defaultFrom = computed<AnimationSnapshot>(() =>
const defaultFrom = computed(() =>
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
);
const defaultTo = computed<AnimationSnapshot[]>(() => [
const defaultTo = computed(() => [
{
filter: 'blur(5px)',
opacity: 0.5,
@@ -93,71 +111,21 @@ const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1);
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
const times = computed(() =>
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
);
const inView = ref(false);
const animationKey = ref(0);
const completionFired = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const setupObserver = () => {
if (!rootRef.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
observer.observe(rootRef.value);
};
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
};
const getTransition = (index: number) => {
return {
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
};
};
const getTransition = (index: number): Transition => ({
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
});
const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && !completionFired.value && props.onAnimationComplete) {
completionFired.value = true;
props.onAnimationComplete();
if (index === elements.value.length - 1) {
props.onAnimationComplete?.();
}
};
onMounted(() => {
setupObserver();
});
onUnmounted(() => {
observer?.disconnect();
});
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect();
setupObserver();
});
watch([() => props.delay, () => props.stepDuration, () => props.animateBy, () => props.direction], () => {
animationKey.value++;
completionFired.value = false;
});
</script>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watchEffect, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
import { animate, Motion, MotionValue, useMotionValue } from 'motion-v';
import { computed, onMounted, watch } from 'vue';
interface CircularTextProps {
text: string;
@@ -10,87 +10,59 @@ interface CircularTextProps {
}
const props = withDefaults(defineProps<CircularTextProps>(), {
text: '',
spinDuration: 20,
onHover: 'speedUp',
className: ''
});
const letters = computed(() => Array.from(props.text));
const isHovered = ref(false);
const rotation: MotionValue<number> = useMotionValue(0);
const currentRotation = ref(0);
const animationId = ref<number | null>(null);
const lastTime = ref<number>(Date.now());
const rotationSpeed = ref<number>(0);
let currentAnimation: ReturnType<typeof animate> | null = null;
const getCurrentSpeed = () => {
if (isHovered.value && props.onHover === 'pause') return 0;
const startRotation = (duration: number) => {
currentAnimation?.stop();
const start = rotation.get();
const baseDuration = props.spinDuration;
const baseSpeed = 360 / baseDuration;
currentAnimation = animate(rotation, start + 360, {
duration,
ease: 'linear',
repeat: Infinity
});
};
if (!isHovered.value) return baseSpeed;
onMounted(() => {
startRotation(props.spinDuration);
});
watch(
() => [props.spinDuration, props.text],
() => {
startRotation(props.spinDuration);
}
);
const handleHoverStart = () => {
if (!props.onHover) return;
switch (props.onHover) {
case 'slowDown':
return baseSpeed / 2;
startRotation(props.spinDuration * 2);
break;
case 'speedUp':
return baseSpeed * 4;
startRotation(props.spinDuration / 4);
break;
case 'pause':
currentAnimation?.stop();
break;
case 'goBonkers':
return baseSpeed * 20;
default:
return baseSpeed;
startRotation(props.spinDuration / 20);
break;
}
};
const getCurrentScale = () => {
return isHovered.value && props.onHover === 'goBonkers' ? 0.8 : 1;
};
const animate = () => {
const now = Date.now();
const deltaTime = (now - lastTime.value) / 1000;
lastTime.value = now;
const targetSpeed = getCurrentSpeed();
const speedDiff = targetSpeed - rotationSpeed.value;
const smoothingFactor = Math.min(1, deltaTime * 5);
rotationSpeed.value += speedDiff * smoothingFactor;
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;
animationId.value = requestAnimationFrame(animate);
};
const startAnimation = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
lastTime.value = Date.now();
rotationSpeed.value = getCurrentSpeed();
animate();
};
watchEffect(() => {
startAnimation();
});
startAnimation();
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
});
const handleHoverStart = () => {
isHovered.value = true;
};
const handleHoverEnd = () => {
isHovered.value = false;
startRotation(props.spinDuration);
};
const getLetterTransform = (index: number) => {
@@ -104,28 +76,24 @@ const getLetterTransform = (index: number) => {
<template>
<Motion
:animate="{
rotate: currentRotation,
scale: getCurrentScale()
tag="div"
:class="[
'm-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center',
className
]"
:style="{
rotate: rotation
}"
:transition="{
rotate: {
duration: 0
},
scale: {
type: 'spring',
damping: 20,
stiffness: 300
}
:initial="{
rotate: 0
}"
:class="`m-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center ${props.className}`"
@mouseenter="handleHoverStart"
@mouseleave="handleHoverEnd"
>
<span
v-for="(letter, i) in letters"
:key="i"
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
class="inline-block absolute inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
:style="{
transform: getLetterTransform(i),
WebkitTransform: getLetterTransform(i)

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
import { computed, onBeforeUnmount, onMounted, useSlots, useTemplateRef, watch } from 'vue';
interface FuzzyTextProps {
text: string;
fontSize?: number | string;
fontWeight?: string | number;
fontFamily?: string;
@@ -10,78 +9,52 @@ interface FuzzyTextProps {
enableHover?: boolean;
baseIntensity?: number;
hoverIntensity?: number;
fuzzRange?: number;
fps?: number;
direction?: 'horizontal' | 'vertical' | 'both';
transitionDuration?: number;
clickEffect?: boolean;
glitchMode?: boolean;
glitchInterval?: number;
glitchDuration?: number;
gradient?: string[] | null;
letterSpacing?: number;
className?: string;
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
text: '',
fontSize: 'clamp(2rem, 8vw, 8rem)',
fontWeight: 900,
fontFamily: 'inherit',
color: '#fff',
enableHover: true,
baseIntensity: 0.18,
hoverIntensity: 0.5
hoverIntensity: 0.5,
fuzzRange: 30,
fps: 60,
direction: 'horizontal',
transitionDuration: 0,
clickEffect: false,
glitchMode: false,
glitchInterval: 2000,
glitchDuration: 200,
gradient: null,
letterSpacing: 0,
className: ''
});
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef');
const canvasRef = useTemplateRef<HTMLCanvasElement & { cleanupFuzzyText?: () => void }>('canvasRef');
const slots = useSlots();
let animationFrameId: number;
let isCancelled = false;
let cleanup: (() => void) | null = null;
let glitchTimeoutId: ReturnType<typeof setTimeout>;
let glitchEndTimeoutId: ReturnType<typeof setTimeout>;
let clickTimeoutId: ReturnType<typeof setTimeout>;
let cancelled = false;
const waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
if (document.fonts.check(fontString)) {
return true;
}
try {
await document.fonts.load(fontString);
return document.fonts.check(fontString);
} catch (error) {
console.warn('Font loading failed:', error);
return false;
}
}
return new Promise(resolve => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(false);
return;
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const testWidth = ctx.measureText('M').width;
let attempts = 0;
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const newWidth = ctx.measureText('M').width;
if (newWidth !== testWidth && newWidth > 0) {
resolve(true);
} else if (attempts < 20) {
attempts++;
setTimeout(checkFont, 50);
} else {
resolve(false);
}
};
setTimeout(checkFont, 10);
});
};
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
if (isCancelled) return;
const text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));
const init = async () => {
const canvas = canvasRef.value;
if (!canvas) return;
@@ -92,184 +65,185 @@ const initCanvas = async () => {
props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily;
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;
let numericFontSize: number;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
try {
await document.fonts.load(fontString);
} catch {
await document.fonts.ready;
}
if (cancelled) return;
let numericFontSize: number;
if (typeof props.fontSize === 'number') {
numericFontSize = props.fontSize;
} else {
const temp = document.createElement('span');
temp.style.fontSize = props.fontSize;
temp.style.fontFamily = computedFontFamily;
document.body.appendChild(temp);
const computedSize = window.getComputedStyle(temp).fontSize;
numericFontSize = parseFloat(computedSize);
numericFontSize = parseFloat(getComputedStyle(temp).fontSize);
document.body.removeChild(temp);
}
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr);
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`);
}
const text = props.text;
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
const offCtx = offscreen.getContext('2d')!;
offCtx.font = fontString;
offCtx.textBaseline = 'alphabetic';
const testMetrics = offCtx.measureText('M');
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas();
}
}, 100);
return;
let totalWidth = 0;
if (props.letterSpacing !== 0) {
for (const char of text.value) {
totalWidth += offCtx.measureText(char).width + props.letterSpacing;
}
totalWidth -= props.letterSpacing;
} else {
totalWidth = offCtx.measureText(text.value).width;
}
const metrics = offCtx.measureText(text.value);
const ascent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const descent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const height = Math.ceil(ascent + descent);
offscreen.width = Math.ceil(totalWidth) + 20;
offscreen.height = height;
offCtx.font = fontString;
offCtx.textBaseline = 'alphabetic';
const metrics = offCtx.measureText(text);
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
if (props.gradient && props.gradient.length >= 2) {
const grad = offCtx.createLinearGradient(0, 0, offscreen.width, 0);
props.gradient.forEach((c, i) => grad.addColorStop(i / (props.gradient!.length - 1), c));
offCtx.fillStyle = grad;
} else {
offCtx.fillStyle = props.color;
}
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
let x = 10;
for (const char of text.value) {
offCtx.fillText(char, x, ascent);
x += offCtx.measureText(char).width + props.letterSpacing;
}
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
const marginX = props.fuzzRange + 20;
const marginY = props.direction === 'vertical' || props.direction === 'both' ? props.fuzzRange + 10 : 0;
offscreen.width = offscreenWidth;
offscreen.height = tightHeight;
const xOffset = extraWidthBuffer / 2;
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = 'alphabetic';
offCtx.fillStyle = props.color;
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
const horizontalMargin = 50;
const verticalMargin = 0;
canvas.width = offscreenWidth + horizontalMargin * 2;
canvas.height = tightHeight + verticalMargin * 2;
ctx.translate(horizontalMargin, verticalMargin);
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
canvas.width = offscreen.width + marginX * 2;
canvas.height = offscreen.height + marginY * 2;
ctx.translate(marginX, marginY);
let isHovering = false;
const fuzzRange = 30;
let isClicking = false;
let isGlitching = false;
let currentIntensity = props.baseIntensity;
let targetIntensity = props.baseIntensity;
let lastFrameTime = 0;
const frameDuration = 1000 / props.fps;
const run = () => {
if (isCancelled) return;
ctx.clearRect(-fuzzRange, -fuzzRange, offscreenWidth + 2 * fuzzRange, tightHeight + 2 * fuzzRange);
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity;
for (let j = 0; j < tightHeight; j++) {
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j, offscreenWidth, 1);
const startGlitch = () => {
if (!props.glitchMode || cancelled) return;
glitchTimeoutId = setTimeout(() => {
isGlitching = true;
glitchEndTimeoutId = setTimeout(() => {
isGlitching = false;
startGlitch();
}, props.glitchDuration);
}, props.glitchInterval);
};
if (props.glitchMode) startGlitch();
const run = (ts: number) => {
if (cancelled) return;
if (ts - lastFrameTime < frameDuration) {
animationFrameId = requestAnimationFrame(run);
return;
}
animationFrameId = window.requestAnimationFrame(run);
lastFrameTime = ts;
ctx.clearRect(-marginX, -marginY, offscreen.width + marginX * 2, offscreen.height + marginY * 2);
targetIntensity = isClicking || isGlitching ? 1 : isHovering ? props.hoverIntensity : props.baseIntensity;
if (props.transitionDuration > 0) {
const step = 1 / (props.transitionDuration / frameDuration);
currentIntensity += Math.sign(targetIntensity - currentIntensity) * step;
currentIntensity = Math.min(
Math.max(currentIntensity, Math.min(targetIntensity, currentIntensity)),
Math.max(targetIntensity, currentIntensity)
);
} else {
currentIntensity = targetIntensity;
}
for (let y = 0; y < offscreen.height; y++) {
const dx = props.direction !== 'vertical' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange : 0;
const dy =
props.direction !== 'horizontal' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange * 0.5 : 0;
ctx.drawImage(offscreen, 0, y, offscreen.width, 1, dx, y + dy, offscreen.width, 1);
}
animationFrameId = requestAnimationFrame(run);
};
run();
animationFrameId = requestAnimationFrame(run);
const isInsideTextArea = (x: number, y: number) =>
x >= interactiveLeft && x <= interactiveRight && y >= interactiveTop && y <= interactiveBottom;
const rectCheck = (x: number, y: number) =>
x >= marginX && x <= marginX + offscreen.width && y >= marginY && y <= marginY + offscreen.height;
const handleMouseMove = (e: MouseEvent) => {
const mouseMove = (e: MouseEvent) => {
if (!props.enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
isHovering = rectCheck(e.clientX - rect.left, e.clientY - rect.top);
};
const handleMouseLeave = () => {
isHovering = false;
};
const mouseLeave = () => (isHovering = false);
const handleTouchMove = (e: TouchEvent) => {
if (!props.enableHover) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleTouchEnd = () => {
isHovering = false;
const click = () => {
if (!props.clickEffect) return;
isClicking = true;
clearTimeout(clickTimeoutId);
clickTimeoutId = setTimeout(() => (isClicking = false), 150);
};
if (props.enableHover) {
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
canvas.addEventListener('mousemove', mouseMove);
canvas.addEventListener('mouseleave', mouseLeave);
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId);
if (props.enableHover) {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
}
};
if (props.clickEffect) {
canvas.addEventListener('click', click);
}
onBeforeUnmount(() => {
cancelled = true;
cancelAnimationFrame(animationFrameId);
clearTimeout(glitchTimeoutId);
clearTimeout(glitchEndTimeoutId);
clearTimeout(clickTimeoutId);
canvas.removeEventListener('mousemove', mouseMove);
canvas.removeEventListener('mouseleave', mouseLeave);
canvas.removeEventListener('click', click);
});
};
onMounted(() => {
nextTick(() => {
initCanvas();
});
});
onUnmounted(() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
});
onMounted(init);
watch(
[
() => props.text,
() => props.fontSize,
() => props.fontWeight,
() => props.fontFamily,
() => props.color,
() => props.enableHover,
() => props.baseIntensity,
() => props.hoverIntensity
],
() => ({ ...props, text: text.value }),
() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
isCancelled = false;
nextTick(() => {
initCanvas();
});
cancelled = true;
cancelAnimationFrame(animationFrameId);
cancelled = false;
init();
}
);
</script>
<template>
<canvas ref="canvasRef" />
<canvas ref="canvasRef" :class="className" />
</template>

View File

@@ -1,75 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, useSlots } from 'vue';
interface GradientTextProps {
text: string;
className?: string;
colors?: string[];
animationSpeed?: number;
showBorder?: boolean;
direction?: 'horizontal' | 'vertical' | 'diagonal';
pauseOnHover?: boolean;
yoyo?: boolean;
}
const props = withDefaults(defineProps<GradientTextProps>(), {
text: '',
className: '',
colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],
colors: () => ['#27FF64', '#27FF64', '#A0FFBC'],
animationSpeed: 8,
showBorder: false
showBorder: false,
direction: 'horizontal',
pauseOnHover: false,
yoyo: true
});
const slots = useSlots();
const text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const animationDuration = props.animationSpeed * 1000;
useAnimationFrame(time => {
if (isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
if (props.yoyo) {
const fullCycle = animationDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration) {
progress.set((cycleTime / animationDuration) * 100);
} else {
progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);
}
} else {
// Continuously increase position for seamless looping
progress.set((elapsedRef.value / animationDuration) * 100);
}
});
const backgroundPosition = useTransform(progress, p => {
if (props.direction === 'horizontal') {
return `${p}% 50%`;
} else if (props.direction === 'vertical') {
return `50% ${p}%`;
} else {
// For diagonal, move only horizontally to avoid interference patterns
return `${p}% 50%`;
}
});
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientAngle = computed(() =>
props.direction === 'horizontal' ? 'to right' : props.direction === 'vertical' ? 'to bottom' : 'to bottom right'
);
// Duplicate first color at the end for seamless looping
const gradientColors = computed(() => [...props.colors, props.colors[0]].join(', '));
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(to right, ${props.colors.join(', ')})`,
animationDuration: `${props.animationSpeed}s`,
backgroundSize: '300% 100%',
'--animation-duration': `${props.animationSpeed}s`
}));
const borderStyle = computed(() => ({
...gradientStyle.value
}));
const textStyle = computed(() => ({
...gradientStyle.value,
backgroundClip: 'text',
WebkitBackgroundClip: 'text'
backgroundImage: `linear-gradient(${gradientAngle.value}, ${gradientColors.value})`,
backgroundSize:
props.direction === 'horizontal' ? '300% 100%' : props.direction === 'vertical' ? '100% 300%' : '300% 300%',
backgroundRepeat: 'repeat'
}));
</script>
<template>
<div
:class="`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${className}`"
<Motion
tag="div"
:class="[
'relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer',
className,
showBorder && 'py-1 px-2'
]"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
<Motion
tag="div"
v-if="showBorder"
class="absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient"
:style="borderStyle"
class="z-0 absolute inset-0 rounded-[1.25rem] pointer-events-none"
:style="{ ...gradientStyle, backgroundPosition }"
>
<div
class="absolute inset-0 bg-black rounded-[1.25rem] z-[-1]"
style="width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)"
class="z-[-1] absolute bg-black rounded-[1.25rem]"
:style="{
width: 'calc(100% - 2px)',
height: 'calc(100% - 2px)',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
}"
/>
</div>
</Motion>
<div class="inline-block relative z-2 text-transparent bg-cover animate-gradient" :style="textStyle">
<Motion
tag="div"
class="inline-block z-2 relative bg-clip-text text-transparent"
:style="{
...gradientStyle,
backgroundPosition,
WebkitBackgroundClip: 'text'
}"
>
{{ text }}
</div>
</div>
</Motion>
</Motion>
</template>
<style scoped>
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-gradient {
animation: gradient var(--animation-duration, 8s) linear infinite;
}
</style>

View File

@@ -1,49 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, watch } from 'vue';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
text: '',
disabled: false,
speed: 5,
className: ''
speed: 2,
className: '',
color: '#b5b5b5',
shineColor: '#ffffff',
spread: 120,
yoyo: false,
pauseOnHover: false,
direction: 'left',
delay: 0
});
const animationDuration = computed(() => `${props.speed}s`);
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const directionRef = ref(props.direction === 'left' ? 1 : -1);
const animationDuration = computed(() => props.speed * 1000);
const delayDuration = computed(() => props.delay * 1000);
useAnimationFrame(time => {
if (props.disabled || isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
// Animation goes from 0 to 100
if (props.yoyo) {
const cycleDuration = animationDuration.value + delayDuration.value;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration.value) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.value === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration.value) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.value === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration.value + delayDuration.value;
const cycleTime = elapsedRef.value % cycleDuration;
if (cycleTime < animationDuration.value) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.value === 1 ? 100 : 0);
}
}
});
watch(
() => props.direction,
() => {
directionRef.value = props.direction === 'left' ? 1 : -1;
elapsedRef.value = 0;
progress.set(0);
},
{
immediate: true
}
);
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}));
</script>
<template>
<div
:class="`text-[#b5b5b5a4] bg-clip-text inline-block ${!props.disabled ? 'animate-shine' : ''} ${props.className}`"
:style="{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
animationDuration: animationDuration
}"
<Motion
tag="span"
:class="['inline-block', className]"
:style="{ ...gradientStyle, backgroundPosition }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ props.text }}
</div>
{{ text }}
</Motion>
</template>
<style scoped>
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
.animate-shine {
animation: shine 5s linear infinite;
}
</style>

View File

@@ -1,18 +1,11 @@
<template>
<p
ref="textRef"
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
:style="{
textAlign,
wordWrap: 'break-word'
}"
>
<component :is="tag" ref="elRef" :style="styles" :class="classes">
{{ text }}
</p>
</component>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
import { ref, onMounted, watch, type CSSProperties, onBeforeUnmount, computed } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
@@ -25,25 +18,27 @@ export interface SplitTextProps {
delay?: number;
duration?: number;
ease?: string | ((t: number) => number);
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
splitType?: 'chars' | 'words' | 'lines';
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
textAlign?: 'left' | 'center' | 'right' | 'justify';
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
textAlign?: CSSProperties['textAlign'];
onLetterAnimationComplete?: () => void;
}
const props = withDefaults(defineProps<SplitTextProps>(), {
className: '',
delay: 100,
duration: 0.6,
delay: 50,
duration: 1.25,
ease: 'power3.out',
splitType: 'chars',
from: () => ({ opacity: 0, y: 40 }),
to: () => ({ opacity: 1, y: 0 }),
threshold: 0.1,
rootMargin: '-100px',
tag: 'p',
textAlign: 'center'
});
@@ -51,144 +46,134 @@ const emit = defineEmits<{
'animation-complete': [];
}>();
const textRef = useTemplateRef<HTMLParagraphElement>('textRef');
const animationCompletedRef = ref(false);
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
const timelineRef = ref<gsap.core.Timeline | null>(null);
const splitterRef = ref<GSAPSplitText | null>(null);
const elRef = ref<HTMLElement | null>(null);
const fontsLoaded = ref(false);
const animationCompleted = ref(false);
const initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text) return;
let splitInstance: GSAPSplitText | null = null;
await nextTick();
const el = textRef.value;
animationCompletedRef.value = false;
const absoluteLines = props.splitType === 'lines';
if (absoluteLines) el.style.position = 'relative';
let splitter: GSAPSplitText;
try {
splitter = new GSAPSplitText(el, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line'
onMounted(() => {
if (document.fonts.status === 'loaded') {
fontsLoaded.value = true;
} else {
document.fonts.ready.then(() => {
fontsLoaded.value = true;
});
splitterRef.value = splitter;
} catch (error) {
console.error('Failed to create SplitText:', error);
return;
}
});
let targets: Element[];
switch (props.splitType) {
case 'lines':
targets = splitter.lines;
break;
case 'words':
targets = splitter.words;
break;
case 'chars':
targets = splitter.chars;
break;
default:
targets = splitter.chars;
const runAnimation = () => {
if (!elRef.value || !props.text || !fontsLoaded.value) return;
if (animationCompleted.value) return;
const el = elRef.value as HTMLElement & {
_rbsplitInstance?: GSAPSplitText;
};
// cleanup previous
if (el._rbsplitInstance) {
try {
el._rbsplitInstance.revert();
} catch {}
el._rbsplitInstance = undefined;
}
if (!targets || targets.length === 0) {
console.warn('No targets found for SplitText animation');
splitter.revert();
return;
}
targets.forEach(t => {
(t as HTMLElement).style.willChange = 'transform, opacity';
});
const startPct = (1 - props.threshold) * 100;
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin);
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`;
const marginUnit = marginMatch?.[2] || 'px';
const sign =
marginValue === 0
? ''
: marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`;
const start = `top ${startPct}%${sign}`;
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start,
toggleActions: 'play none none none',
once: true,
onToggle: self => {
scrollTriggerRef.value = self;
}
},
smoothChildTiming: true,
onComplete: () => {
animationCompletedRef.value = true;
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true
});
props.onLetterAnimationComplete?.();
emit('animation-complete');
let targets: Element[] = [];
const assignTargets = (self: GSAPSplitText) => {
if (props.splitType.includes('chars') && self.chars?.length) targets = self.chars;
if (!targets.length && props.splitType.includes('words') && self.words?.length) targets = self.words;
if (!targets.length && props.splitType.includes('lines') && self.lines?.length) targets = self.lines;
if (!targets.length) targets = self.chars || self.words || self.lines;
};
splitInstance = new GSAPSplitText(el, {
type: props.splitType,
smartWrap: true,
autoSplit: props.splitType === 'lines',
linesClass: 'split-line',
wordsClass: 'split-word',
charsClass: 'split-char',
reduceWhiteSpace: false,
onSplit(self) {
assignTargets(self);
return gsap.fromTo(
targets,
{ ...props.from },
{
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
scrollTrigger: {
trigger: el,
start,
once: true,
fastScrollEnd: true,
anticipatePin: 0.4
},
onComplete() {
animationCompleted.value = true;
props.onLetterAnimationComplete?.();
emit('animation-complete');
},
willChange: 'transform, opacity',
force3D: true
}
);
}
});
timelineRef.value = tl;
tl.set(targets, { ...props.from, immediateRender: false, force3D: true });
tl.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true
});
el._rbsplitInstance = splitInstance;
};
const cleanup = () => {
if (timelineRef.value) {
timelineRef.value.kill();
timelineRef.value = null;
}
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill();
scrollTriggerRef.value = null;
}
if (splitterRef.value) {
gsap.killTweensOf(textRef.value);
splitterRef.value.revert();
splitterRef.value = null;
}
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
cleanup();
});
watch(
[
() => props.text,
() => props.delay,
() => props.duration,
() => props.ease,
() => props.splitType,
() => props.from,
() => props.to,
() => props.threshold,
() => props.rootMargin,
() => props.onLetterAnimationComplete
() => [
props.text,
props.delay,
props.duration,
props.ease,
props.splitType,
JSON.stringify(props.from),
JSON.stringify(props.to),
props.threshold,
props.rootMargin,
fontsLoaded.value
],
() => {
cleanup();
initializeAnimation();
}
runAnimation,
{ deep: true }
);
onBeforeUnmount(() => {
ScrollTrigger.getAll().forEach(st => {
if (st.trigger === elRef.value) st.kill();
});
try {
splitInstance?.revert();
} catch {}
});
const styles = computed(() => ({
textAlign: props.textAlign,
wordWrap: 'break-word',
willChange: 'transform, opacity'
}));
const classes = computed(() => `split-parent overflow-hidden inline-block whitespace-normal ${props.className}`);
</script>

View File

@@ -1,33 +1,24 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px] overflow-hidden">
<div class="h-[400px] overflow-hidden demo-container">
<RefreshButton @refresh="forceRerender" />
<BlurText
:key="rerenderKey"
text="Isn't this so cool?!"
:delay="delay"
class-name="blur-text-demo"
:animate-by="animateBy"
:direction="direction"
:threshold="threshold"
:root-margin="rootMargin"
:step-duration="stepDuration"
@animation-complete="
() => {
showCallback && showToast();
}
"
:delay="delay"
class-name="blur-text-demo"
@animation-complete="showToast"
/>
</div>
<Customize>
<PreviewSwitch title="Show Completion Toast" v-model="showCallback" />
<div class="flex gap-4 flex-wrap">
<div class="flex flex-wrap gap-4">
<button
class="text-xs bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-8 px-3 transition-colors cursor-pointer"
class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleAnimateBy"
>
Animate By:
@@ -35,7 +26,7 @@
</button>
<button
class="text-xs bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-8 px-3 transition-colors cursor-pointer"
class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleDirection"
>
Direction:
@@ -43,11 +34,15 @@
</button>
</div>
<PreviewSlider title="Delay (ms)" v-model="delay" :min="50" :max="500" :step="10" />
<PreviewSlider title="Step Duration (s)" v-model="stepDuration" :min="0.1" :max="1" :step="0.05" />
<PreviewSlider title="Threshold" v-model="threshold" :min="0.1" :max="1" :step="0.1" />
<PreviewSlider
title="Delay"
v-model="delay"
:min="50"
:max="500"
:step="10"
value-unit="ms"
@update:model-value="forceRerender"
/>
</Customize>
<PropTable :data="propData" />
@@ -66,30 +61,26 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import RefreshButton from '../../components/common/RefreshButton.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 PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import BlurText from '../../content/TextAnimations/BlurText/BlurText.vue';
import { blurText } from '@/constants/code/TextAnimations/blurTextCode';
import { useToast } from 'primevue/usetoast';
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 PropTable from '@/components/common/PropTable.vue';
import RefreshButton from '@/components/common/RefreshButton.vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import { useForceRerender } from '@/composables/useForceRerender';
import { blurText } from '@/constants/code/TextAnimations/blurTextCode';
import BlurText from '@/content/TextAnimations/BlurText/BlurText.vue';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
const { rerenderKey, forceRerender } = useForceRerender();
const toast = useToast();
const animateBy = ref<'words' | 'letters'>('words');
const direction = ref<'top' | 'bottom'>('top');
const delay = ref(200);
const stepDuration = ref(0.35);
const threshold = ref(0.1);
const rootMargin = ref('0px');
const showCallback = ref(true);
const toast = useToast();
const { rerenderKey, forceRerender } = useForceRerender();
const toggleAnimateBy = () => {
animateBy.value = animateBy.value === 'words' ? 'letters' : 'words';
@@ -110,18 +101,23 @@ const showToast = () => {
};
const propData = [
{ name: 'text', type: 'string', default: '""', description: 'The text content to animate.' },
{
name: 'text',
type: 'string',
default: '""',
description: 'The text content to animate.'
},
{
name: 'animateBy',
type: 'string',
default: '"words"',
description: 'Determines whether to animate by "words" or "letters".'
description: "Determines whether to animate by 'words' or 'letters'."
},
{
name: 'direction',
type: 'string',
default: '"top"',
description: 'Direction from which the words/letters appear ("top" or "bottom").'
description: "Direction from which the words/letters appear ('top' or 'bottom')."
},
{
name: 'delay',
@@ -141,16 +137,12 @@ const propData = [
default: '0.1',
description: 'Intersection threshold for triggering the animation.'
},
{ name: 'rootMargin', type: 'string', default: '"0px"', description: 'Root margin for the intersection observer.' },
{ name: 'className', type: 'string', default: '""', description: 'Additional class names to style the component.' },
{ name: 'animationFrom', type: 'object', default: 'undefined', description: 'Custom initial animation properties.' },
{
name: 'animationTo',
type: 'array',
default: 'undefined',
description: 'Custom target animation properties array.'
name: 'rootMargin',
type: 'string',
default: '"0px"',
description: 'Root margin for the intersection observer.'
},
{ name: 'easing', type: 'function', default: '(t) => t', description: 'Custom easing function for the animation.' },
{
name: 'onAnimationComplete',
type: 'function',

View File

@@ -1,28 +1,14 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px] overflow-hidden">
<CircularText
:key="rerenderKey"
text="VUE * BITS * IS * AWESOME * "
:spin-duration="spinDuration"
:on-hover="onHover"
class-name="text-blue-500"
/>
<div class="h-[400px] overflow-hidden demo-container">
<CircularText :key="rerenderKey" :text="text" :spin-duration="spinDuration" :on-hover="onHover" />
</div>
<Customize>
<div class="flex gap-4 flex-wrap">
<button
class="text-xs bg-[#0b0b0b] rounded-[10px] border border-[#1e3721] hover:bg-[#1e3721] text-white h-8 px-3 transition-colors"
@click="toggleOnHover"
>
On Hover:
<span class="text-[#a1a1aa]">&nbsp;{{ onHover }}</span>
</button>
</div>
<PreviewSlider title="Spin Duration (s)" v-model="spinDuration" :min="1" :max="50" :step="1" />
<PreviewText title="Text" v-model="text" />
<PreviewSelect title="On Hover" v-model="onHover" :options="hoverOptions" />
<PreviewSlider title="Spin Duration (s)" v-model="spinDuration" :min="1" :max="60" :step="1" />
</Customize>
<PropTable :data="propData" />
@@ -41,45 +27,58 @@
</template>
<script setup lang="ts">
import { ref } 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 PreviewSlider from '../../components/common/PreviewSlider.vue';
import CircularText from '../../content/TextAnimations/CircularText/CircularText.vue';
import { circularText } from '@/constants/code/TextAnimations/circularTextCode';
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 PreviewSelect from '@/components/common/PreviewSelect.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import PreviewText from '@/components/common/PreviewText.vue';
import PropTable from '@/components/common/PropTable.vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import { useForceRerender } from '@/composables/useForceRerender';
import { circularText } from '@/constants/code/TextAnimations/circularTextCode';
import CircularText from '@/content/TextAnimations/CircularText/CircularText.vue';
import { ref } from 'vue';
const { rerenderKey } = useForceRerender();
const text = ref('VUE*BITS*COMPONENTS*');
const onHover = ref<'slowDown' | 'speedUp' | 'pause' | 'goBonkers'>('speedUp');
const spinDuration = ref(20);
const { rerenderKey, forceRerender } = useForceRerender();
const hoverOptions: Array<'slowDown' | 'speedUp' | 'pause' | 'goBonkers'> = [
'slowDown',
'speedUp',
'pause',
'goBonkers'
const hoverOptions = [
{ label: 'Slow Down', value: 'slowDown' },
{ label: 'Speed Up', value: 'speedUp' },
{ label: 'Pause', value: 'pause' },
{ label: 'Go Bonkers', value: 'goBonkers' }
];
const toggleOnHover = () => {
const currentIndex = hoverOptions.indexOf(onHover.value);
const nextIndex = (currentIndex + 1) % hoverOptions.length;
onHover.value = hoverOptions[nextIndex];
forceRerender();
};
const propData = [
{ name: 'text', type: 'string', default: '""', description: 'The text content to display in a circular pattern.' },
{ name: 'spinDuration', type: 'number', default: '20', description: 'Duration of one full rotation in seconds.' },
{
name: 'text',
type: 'string',
default: "''",
description: 'The text to display in a circular layout.'
},
{
name: 'spinDuration',
type: 'number',
default: '20',
description: 'The duration (in seconds) for one full rotation.'
},
{
name: 'onHover',
type: 'string',
default: '"speedUp"',
description: 'Hover behavior: "slowDown", "speedUp", "pause", or "goBonkers".'
type: "'slowDown' | 'speedUp' | 'pause' | 'goBonkers'",
default: 'undefined',
description:
"Specifies the hover behavior variant. Options include 'slowDown', 'speedUp', 'pause', and 'goBonkers'."
},
{ name: 'className', type: 'string', default: '""', description: 'Additional class names to style the component.' }
{
name: 'className',
type: 'string',
default: "''",
description: 'Optional additional CSS classes to apply to the component.'
}
];
</script>

View File

@@ -1,37 +1,74 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[500px] overflow-hidden">
<div class="flex flex-col items-center justify-center h-full">
<div class="h-[500px] overflow-hidden demo-container">
<div class="flex flex-col justify-center items-center h-full">
<FuzzyText
:key="`main-${rerenderKey}`"
text="404"
:base-intensity="baseIntensity"
:hover-intensity="hoverIntensity"
:enable-hover="enableHover"
:fuzz-range="fuzzRange"
:fps="fps"
:direction="direction"
:transition-duration="transitionDuration"
:click-effect="clickEffect"
:glitch-mode="glitchMode"
:glitch-interval="glitchInterval"
:glitch-duration="glitchDuration"
:letter-spacing="letterSpacing"
:font-size="140"
/>
>
404
</FuzzyText>
<div class="my-1" />
<FuzzyText
:key="`sub-${rerenderKey}`"
text="not found"
:base-intensity="baseIntensity"
:hover-intensity="hoverIntensity"
:enable-hover="enableHover"
:fuzz-range="fuzzRange"
:fps="fps"
:direction="direction"
:transition-duration="transitionDuration"
:click-effect="clickEffect"
:glitch-mode="glitchMode"
:glitch-interval="glitchInterval"
:glitch-duration="glitchDuration"
:letter-spacing="letterSpacing"
:font-size="70"
font-family="Gochi Hand"
/>
>
not found
</FuzzyText>
</div>
</div>
<Customize>
<PreviewSlider title="Base Intensity" v-model="baseIntensity" :min="0" :max="1" :step="0.01" />
<PreviewSlider title="Hover Intensity" v-model="hoverIntensity" :min="0" :max="2" :step="0.01" />
<PreviewSlider title="Fuzz Range" v-model="fuzzRange" :min="5" :max="100" :step="1" />
<PreviewSlider title="FPS" v-model="fps" :min="10" :max="120" :step="5" />
<PreviewSlider title="Transition Duration" v-model="transitionDuration" :min="0" :max="60" :step="1" />
<PreviewSlider title="Letter Spacing" v-model="letterSpacing" :min="-10" :max="50" :step="1" />
<PreviewSelect title="Direction" v-model="direction" :options="directionOptions" />
<PreviewSwitch title="Enable Hover" v-model="enableHover" />
<PreviewSwitch title="Click Effect" v-model="clickEffect" />
<PreviewSwitch title="Glitch Mode" v-model="glitchMode" />
<PreviewSlider
title="Glitch Interval"
v-model="glitchInterval"
:min="500"
:max="5000"
:step="100"
:disabled="!glitchMode"
/>
<PreviewSlider
title="Glitch Duration"
v-model="glitchDuration"
:min="50"
:max="1000"
:step="50"
:disabled="!glitchMode"
/>
</Customize>
<PropTable :data="propData" />
@@ -48,35 +85,47 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import PropTable from '../../components/common/PropTable.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import FuzzyText from '../../content/TextAnimations/FuzzyText/FuzzyText.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.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 { fuzzyText } from '@/constants/code/TextAnimations/fuzzyTextCode';
import { useForceRerender } from '@/composables/useForceRerender';
import FuzzyText from '@/content/TextAnimations/FuzzyText/FuzzyText.vue';
import { ref } from 'vue';
const baseIntensity = ref(0.2);
const hoverIntensity = ref(0.5);
const enableHover = ref(true);
const fuzzRange = ref(30);
const fps = ref(60);
const direction = ref<'horizontal' | 'vertical' | 'both'>('horizontal');
const transitionDuration = ref(0);
const clickEffect = ref(false);
const glitchMode = ref(false);
const glitchInterval = ref(2000);
const glitchDuration = ref(200);
const letterSpacing = ref(0);
const { rerenderKey } = useForceRerender();
const directionOptions = [
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
{ value: 'both', label: 'Both' }
];
const propData = [
{
name: 'text',
name: 'slot',
type: 'string',
default: '""',
default: '',
description: 'The text content to display inside the fuzzy text component.'
},
{
name: 'fontSize',
type: 'number | string',
default: '"clamp(2rem, 8vw, 8rem)"',
default: `"clamp(2rem, 8vw, 8rem)"`,
description:
'Specifies the font size of the text. Accepts any valid CSS font-size value or a number (interpreted as pixels).'
},
@@ -89,8 +138,8 @@ const propData = [
{
name: 'fontFamily',
type: 'string',
default: '"inherit"',
description: 'Specifies the font family of the text. "inherit" uses the computed style from the parent.'
default: `"inherit"`,
description: "Specifies the font family of the text. 'inherit' uses the computed style from the parent."
},
{
name: 'color',
@@ -115,6 +164,72 @@ const propData = [
type: 'number',
default: '0.5',
description: 'The fuzz intensity when the text is hovered.'
},
{
name: 'fuzzRange',
type: 'number',
default: '30',
description: 'Maximum pixel displacement for the fuzzy effect.'
},
{
name: 'fps',
type: 'number',
default: '60',
description: 'Frame rate cap for the animation. Lower values reduce CPU usage.'
},
{
name: 'direction',
type: `'horizontal' | 'vertical' | 'both'`,
default: `'horizontal'`,
description: 'The axis/axes for the fuzzy displacement effect.'
},
{
name: 'transitionDuration',
type: 'number',
default: '0',
description: 'Number of frames to ease between intensity states for smooth transitions.'
},
{
name: 'clickEffect',
type: 'boolean',
default: 'false',
description: 'Enables a momentary burst of maximum intensity on click.'
},
{
name: 'glitchMode',
type: 'boolean',
default: 'false',
description: 'Enables periodic random intensity spikes for a glitch effect.'
},
{
name: 'glitchInterval',
type: 'number',
default: '2000',
description: 'Milliseconds between glitch bursts when glitchMode is enabled.'
},
{
name: 'glitchDuration',
type: 'number',
default: '200',
description: 'Milliseconds duration of each glitch burst.'
},
{
name: 'gradient',
type: 'string[] | null',
default: 'null',
description: 'Array of colors to create a gradient text effect (e.g. ["#ff0000", "#00ff00"]).'
},
{
name: 'letterSpacing',
type: 'number',
default: '0',
description: 'Extra pixels between characters.'
},
{
name: 'className',
type: 'string',
default: `''`,
description: 'CSS class for the canvas element.'
}
];
</script>

View File

@@ -2,69 +2,77 @@
<div class="gradient-text-demo">
<TabbedLayout>
<template #preview>
<h2 class="demo-title-extra">Default</h2>
<div class="demo-container h-[200px]">
<div class="text-[2rem]">
<GradientText
text="Add a splash of color!"
:colors="gradientPreview"
:animation-speed="speed"
:show-border="false"
/>
</div>
</div>
<h2 class="demo-title-extra">Border Animation</h2>
<div class="demo-container h-[200px]">
<div class="text-[2rem]">
<GradientText
text="Now with a cool border!"
:colors="gradientPreview"
:animation-speed="speed"
:show-border="true"
class-name="custom-gradient-class"
/>
</div>
<div class="relative h-[400px] text-5xl demo-container">
<GradientText
:colors="colors"
:animation-speed="animationSpeed"
:direction="direction"
:pause-on-hover="pauseOnHover"
:yoyo="yoyo"
:show-border="showBorder"
>
Gradient Magic
</GradientText>
</div>
<Customize>
<PreviewSlider title="Loop Duration" v-model="speed" :min="1" :max="10" :step="0.5" value-unit="s" />
<PreviewSlider
title="Animation Speed"
v-model="animationSpeed"
:min="1"
:max="20"
:step="0.5"
value-unit="s"
/>
<PreviewSelect title="Direction" v-model="direction" :options="directionOptions" />
<PreviewSwitch title="Yoyo Mode" v-model="yoyo" />
<PreviewSwitch title="Pause on Hover" v-model="pauseOnHover" />
<PreviewSwitch title="Show Border" v-model="showBorder" />
<div class="flex flex-col gap-0">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Colors</label>
<label class="block mb-2 font-medium text-sm">Colors</label>
<input
v-model="colors"
type="text"
placeholder="Enter colors separated by commas"
maxlength="100"
class="w-[300px] px-3 py-2 bg-[#0b0b0b] border border-[#333] rounded-md text-white focus:outline-none focus:border-[#666]"
/>
<div class="flex flex-wrap gap-2 px-1 pt-1">
<div v-for="(color, index) in colors" :key="index" class="relative w-8 h-8">
<div
class="relative border-[#222] border-2 rounded-md w-8 h-8 overflow-hidden"
:style="{ backgroundColor: color }"
>
<input
type="color"
:value="color"
@input="updateColor(index, ($event.target as HTMLInputElement).value)"
class="absolute inset-0 opacity-0 w-8 h-8 cursor-pointer"
/>
</div>
<button
v-if="colors.length > 2"
@click="removeColor(index)"
class="-top-1.5 -right-1.5 absolute flex justify-center items-center bg-[#170D27] border border-[#222] rounded-full w-4 h-4 cursor-pointer"
>
<i class="text-[#8BC79A] text-[8px]! pi pi-times" />
</button>
</div>
<button
v-if="colors.length < 8"
@click="addColor"
class="flex justify-center items-center border-[#392e4e] border-2 hover:border-[#27FF64] border-dashed rounded-md w-8 h-8 transition-colors cursor-pointer"
>
<i class="text-[#8BC79A] text-sm pi pi-plus" />
</button>
</div>
<div
class="w-[300px] h-3 rounded-md border border-[#333333]"
class="mt-3 border border-[#333333] rounded-md w-[300px] h-3"
:style="{
background: `linear-gradient(to right, ${gradientPreview.join(', ')})`
background: `linear-gradient(to right, ${gradientPreview})`
}"
/>
</div>
</Customize>
<p class="demo-extra-info mt-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
For a smoother animation, the gradient should start and end with the same color.
</p>
<PropTable :data="propData" />
</template>
@@ -80,27 +88,57 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import PropTable from '../../components/common/PropTable.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import GradientText from '../../content/TextAnimations/GradientText/GradientText.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewSelect from '@/components/common/PreviewSelect.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 { gradientText } from '@/constants/code/TextAnimations/gradientTextCode';
import GradientText from '@/content/TextAnimations/GradientText/GradientText.vue';
import { computed, ref } from 'vue';
const colors = ref('#40ffaa, #4079ff, #40ffaa, #4079ff, #40ffaa');
const speed = ref(3);
const colors = ref(['#27FF64', '#27FF64', '#A0FFBC']);
const animationSpeed = ref(8);
const direction = ref<'horizontal' | 'vertical' | 'diagonal'>('horizontal');
const pauseOnHover = ref(false);
const yoyo = ref(true);
const showBorder = ref(false);
const gradientPreview = computed(() => colors.value.split(',').map(color => color.trim()));
const directionOptions = [
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
{ value: 'diagonal', label: 'Diagonal' }
];
const gradientPreview = computed(() => [...colors.value, colors.value[0]].join(', '));
const updateColor = (index: number, newColor: string) => {
const newColors = [...colors.value];
newColors[index] = newColor;
colors.value = newColors;
};
const addColor = () => {
if (colors.value.length < 8) {
colors.value.push('#ffffff');
}
};
const removeColor = (index: number) => {
if (colors.value.length > 2) {
colors.value.splice(index, 1);
}
};
const propData = [
{
name: 'text',
name: 'slot',
type: 'string',
default: '""',
description: 'The text content to be displayed with gradient effect.'
default: '-',
description: 'The content to be displayed inside the gradient text.'
},
{
name: 'className',
@@ -111,20 +149,38 @@ const propData = [
{
name: 'colors',
type: 'string[]',
default: '["#ffaa40", "#9c40ff", "#ffaa40"]',
description: 'Defines the gradient colors for the text or border.'
default: `["#5227FF", "#FF9FFC", "#B19EEF"]`,
description: 'Array of colors for the gradient effect.'
},
{
name: 'animationSpeed',
type: 'number',
default: '8',
description: 'The duration of the gradient animation in seconds.'
description: 'Duration of one animation cycle in seconds.'
},
{
name: 'direction',
type: `'horizontal' | 'vertical' | 'diagonal'`,
default: `'horizontal'`,
description: 'Direction of the gradient animation.'
},
{
name: 'pauseOnHover',
type: 'boolean',
default: 'false',
description: 'Pauses the animation when hovering over the text.'
},
{
name: 'yoyo',
type: 'boolean',
default: 'true',
description: 'Reverses animation direction at the end instead of looping.'
},
{
name: 'showBorder',
type: 'boolean',
default: 'false',
description: 'Determines whether a border with the gradient effect is displayed.'
description: 'Displays a gradient border around the text.'
}
];
</script>

View File

@@ -1,33 +1,31 @@
<template>
<TabbedLayout>
<template #preview>
<h2 class="demo-title-extra">Basic</h2>
<div class="demo-container h-[200px]">
<ShinyText text="Just some shiny text!" :disabled="false" :speed="3" class-name="shiny-text-demo" />
</div>
<h2 class="demo-title-extra">Button Text</h2>
<div class="demo-container h-[200px]">
<div class="shiny-button">
<ShinyText text="Shiny Button" :disabled="false" :speed="3" class-name="shiny-text-demo" />
</div>
</div>
<h2 class="demo-title-extra">Configurable Speed</h2>
<div class="demo-container h-[200px]">
<div class="h-[400px] text-3xl demo-container">
<ShinyText
:text="speed < 2.5 ? '🐎 This is fast!' : '🐌 This is slow!'"
:disabled="false"
text="✨ Shiny Text Effect"
:delay="delay"
:speed="speed"
class-name="shiny-text-demo"
:color="color"
:shine-color="shineColor"
:spread="spread"
:direction="direction"
:yoyo="yoyo"
:pause-on-hover="pauseOnHover"
:disabled="disabled"
/>
</div>
<Customize>
<PreviewSlider title="Animation Duration" v-model="speed" :min="1" :max="5" :step="0.1" value-unit="s" />
<PreviewSlider title="Speed" v-model="speed" :min="0.5" :max="5" :step="0.1" value-unit="s" />
<PreviewSlider title="Delay" v-model="delay" :min="0" :max="3" :step="0.1" value-unit="s" />
<PreviewSlider title="Spread" v-model="spread" :min="0" :max="180" :step="5" value-unit="deg" />
<PreviewColor title="Color" v-model="color" class="mb-2" />
<PreviewColor title="Shine Color" v-model="shineColor" class="mb-2" />
<PreviewSelect title="Direction" v-model="direction" :options="directionOptions" />
<PreviewSwitch title="Yoyo Mode" v-model="yoyo" />
<PreviewSwitch title="Pause on Hover" v-model="pauseOnHover" />
<PreviewSwitch title="Disabled" v-model="disabled" />
</Customize>
<PropTable :data="propData" />
@@ -44,17 +42,33 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import PropTable from '../../components/common/PropTable.vue';
import CliInstallation from '../../components/code/CliInstallation.vue';
import CodeExample from '../../components/code/CodeExample.vue';
import Customize from '../../components/common/Customize.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import ShinyText from '../../content/TextAnimations/ShinyText/ShinyText.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 PreviewSelect from '@/components/common/PreviewSelect.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 { shinyText } from '@/constants/code/TextAnimations/shinyTextCode';
import ShinyText from '@/content/TextAnimations/ShinyText/ShinyText.vue';
import { ref } from 'vue';
const speed = ref(3);
const speed = ref(2);
const delay = ref(0);
const color = ref('#b5b5b5');
const shineColor = ref('#ffffff');
const spread = ref(120);
const direction = ref<'left' | 'right'>('left');
const yoyo = ref(false);
const pauseOnHover = ref(false);
const disabled = ref(false);
const directionOptions = [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' }
];
const propData = [
{
@@ -64,16 +78,58 @@ const propData = [
description: 'The text to be displayed with the shiny effect.'
},
{
name: 'disabled',
type: 'boolean',
default: 'false',
description: 'Disables the shiny effect when set to true.'
name: 'color',
type: 'string',
default: '"#b5b5b5"',
description: 'The base color of the text.'
},
{
name: 'shineColor',
type: 'string',
default: '"#ffffff"',
description: 'The color of the shine/highlight effect.'
},
{
name: 'speed',
type: 'number',
default: '5',
description: 'Specifies the duration of the animation in seconds.'
default: '2',
description: 'Duration of one animation cycle in seconds.'
},
{
name: 'delay',
type: 'number',
default: '0',
description: 'Pause duration (in seconds) between animation cycles.'
},
{
name: 'spread',
type: 'number',
default: '120',
description: 'The angle (in degrees) of the gradient spread.'
},
{
name: 'yoyo',
type: 'boolean',
default: 'false',
description: 'If true, the animation reverses direction instead of looping.'
},
{
name: 'pauseOnHover',
type: 'boolean',
default: 'false',
description: 'Pauses the animation when the user hovers over the text.'
},
{
name: 'direction',
type: "'left' | 'right'",
default: '"left"',
description: 'The direction the shine moves across the text.'
},
{
name: 'disabled',
type: 'boolean',
default: 'false',
description: 'Disables the shiny effect when set to true.'
},
{
name: 'className',

View File

@@ -1,7 +1,7 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px]">
<div class="h-[400px] demo-container">
<RefreshButton @refresh="forceRerender" />
<SplitText
@@ -11,8 +11,7 @@
:duration="duration"
:ease="ease"
:split-type="splitType"
:threshold="threshold"
class="split-text-demo"
class-name="split-text-demo"
@animation-complete="
() => {
showCallback && showToast();
@@ -22,13 +21,27 @@
</div>
<Customize>
<PreviewSwitch title="Show Completion Toast" v-model="showCallback" />
<div class="flex flex-wrap gap-4">
<button
class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleSplitType"
>
Split Type:
<span class="text-[#a1a1aa]">&nbsp;{{ splitType }}</span>
</button>
<button
class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleEase"
>
Ease:
<span class="text-[#a1a1aa]">&nbsp;{{ ease }}</span>
</button>
</div>
<PreviewSlider title="Stagger Delay (ms)" v-model="delay" :min="10" :max="500" :step="10" />
<PreviewSlider title="Duration (s)" v-model="duration" :min="0.1" :max="3" :step="0.1" />
<PreviewSlider title="Threshold" v-model="threshold" :min="0.1" :max="1" :step="0.1" />
<PreviewSlider title="Duration (s)" v-model="duration" :min="0.1" :max="2" :step="0.1" />
<PreviewSwitch title="Show Completion Toast" v-model="showCallback" />
</Customize>
<PropTable :data="propData" />
@@ -47,29 +60,40 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue';
import RefreshButton from '../../components/common/RefreshButton.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 PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import SplitText from '../../content/TextAnimations/SplitText/SplitText.vue';
import { splitText } from '@/constants/code/TextAnimations/splitTextCode';
import { useToast } from 'primevue/usetoast';
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 RefreshButton from '@/components/common/RefreshButton.vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import { useForceRerender } from '@/composables/useForceRerender';
import { splitText } from '@/constants/code/TextAnimations/splitTextCode';
import SplitText from '@/content/TextAnimations/SplitText/SplitText.vue';
import { useToast } from 'primevue/usetoast';
import { ref, watch } from 'vue';
const delay = ref(10);
const duration = ref(3);
const ease = ref('elastic.out(1, 0.3)');
const splitType = ref<'chars' | 'words' | 'lines' | 'words, chars'>('chars');
const threshold = ref(0.1);
const showCallback = ref(true);
const toast = useToast();
const { rerenderKey, forceRerender } = useForceRerender();
const toast = useToast();
const delay = ref(50);
const duration = ref(1.25);
const ease = ref<'power3.out' | 'bounce.out' | 'elastic.out(1, 0.3)'>('power3.out');
const splitType = ref<'chars' | 'words' | 'lines'>('chars');
const showCallback = ref(true);
const toggleSplitType = () => {
splitType.value = splitType.value === 'chars' ? 'words' : splitType.value === 'words' ? 'lines' : 'chars';
forceRerender();
};
const toggleEase = () => {
ease.value =
ease.value === 'power3.out' ? 'bounce.out' : ease.value === 'bounce.out' ? 'elastic.out(1, 0.3)' : 'power3.out';
forceRerender();
};
const showToast = () => {
toast.add({
@@ -80,10 +104,31 @@ const showToast = () => {
};
const propData = [
{
name: 'tag',
type: 'string',
default: '"p"',
description: 'HTML tag to render: "h1", "h2", "h3", "h4", "h5", "h6", "p",'
},
{ name: 'text', type: 'string', default: '""', description: 'The text content to animate.' },
{ name: 'className', type: 'string', default: '""', description: 'Additional class names to style the component.' },
{ name: 'delay', type: 'number', default: '100', description: 'Delay between animations for each letter (in ms).' },
{ name: 'duration', type: 'number', default: '0.6', description: 'Duration of each letter animation (in seconds).' },
{
name: 'className',
type: 'string',
default: '""',
description: 'Additional class names to style the component.'
},
{
name: 'delay',
type: 'number',
default: '50',
description: 'Delay between animations for each letter (in ms).'
},
{
name: 'duration',
type: 'number',
default: '1.25',
description: 'Duration of each letter animation (in seconds).'
},
{ name: 'ease', type: 'string', default: '"power3.out"', description: 'GSAP easing function for the animation.' },
{
name: 'splitType',
@@ -114,7 +159,7 @@ const propData = [
name: 'textAlign',
type: 'string',
default: '"center"',
description: 'Text alignment: "left", "center", "right", etc.'
description: "Text alignment: 'left', 'center', 'right', etc."
},
{
name: 'onLetterAnimationComplete',

View File

@@ -1,7 +1,7 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container py-[64px] h-[350px]">
<div class="py-[64px] h-[350px] demo-container">
<TextType
:key="key"
:text="texts"
@@ -45,6 +45,7 @@
:max="150"
:step="5"
value-unit="ms"
:disabled="!variableSpeedEnabled"
/>
<PreviewSlider
v-model="variableSpeedMax"
@@ -53,6 +54,7 @@
:max="300"
:step="5"
value-unit="ms"
:disabled="!variableSpeedEnabled"
/>
</Customize>