mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
5.2 KiB
JSON
1 line
5.2 KiB
JSON
{"name":"SplitText","title":"SplitText","description":"Splits text into characters / words for staggered entrance animation.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <component :is=\"tag\" ref=\"elRef\" :style=\"styles\" :class=\"classes\">\n {{ text }}\n </component>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, watch, type CSSProperties, onBeforeUnmount, computed } from 'vue';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText);\n\nexport interface SplitTextProps {\n text: string;\n className?: string;\n delay?: number;\n duration?: number;\n ease?: string | ((t: number) => number);\n splitType?: 'chars' | 'words' | 'lines';\n from?: gsap.TweenVars;\n to?: gsap.TweenVars;\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: CSSProperties['textAlign'];\n onLetterAnimationComplete?: () => void;\n}\n\nconst props = withDefaults(defineProps<SplitTextProps>(), {\n className: '',\n delay: 50,\n duration: 1.25,\n ease: 'power3.out',\n splitType: 'chars',\n from: () => ({ opacity: 0, y: 40 }),\n to: () => ({ opacity: 1, y: 0 }),\n threshold: 0.1,\n rootMargin: '-100px',\n tag: 'p',\n textAlign: 'center'\n});\n\nconst emit = defineEmits<{\n 'animation-complete': [];\n}>();\n\nconst elRef = ref<HTMLElement | null>(null);\nconst fontsLoaded = ref(false);\nconst animationCompleted = ref(false);\n\nlet splitInstance: GSAPSplitText | null = null;\n\nonMounted(() => {\n if (document.fonts.status === 'loaded') {\n fontsLoaded.value = true;\n } else {\n document.fonts.ready.then(() => {\n fontsLoaded.value = true;\n });\n }\n});\n\nconst runAnimation = () => {\n if (!elRef.value || !props.text || !fontsLoaded.value) return;\n if (animationCompleted.value) return;\n\n const el = elRef.value as HTMLElement & {\n _rbsplitInstance?: GSAPSplitText;\n };\n\n // cleanup previous\n if (el._rbsplitInstance) {\n try {\n el._rbsplitInstance.revert();\n } catch {}\n el._rbsplitInstance = undefined;\n }\n\n const startPct = (1 - props.threshold) * 100;\n const marginMatch = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin);\n const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;\n const marginUnit = marginMatch?.[2] || 'px';\n\n const sign =\n marginValue === 0\n ? ''\n : marginValue < 0\n ? `-=${Math.abs(marginValue)}${marginUnit}`\n : `+=${marginValue}${marginUnit}`;\n\n const start = `top ${startPct}%${sign}`;\n\n let targets: Element[] = [];\n\n const assignTargets = (self: GSAPSplitText) => {\n if (props.splitType.includes('chars') && self.chars?.length) targets = self.chars;\n if (!targets.length && props.splitType.includes('words') && self.words?.length) targets = self.words;\n if (!targets.length && props.splitType.includes('lines') && self.lines?.length) targets = self.lines;\n if (!targets.length) targets = self.chars || self.words || self.lines;\n };\n\n splitInstance = new GSAPSplitText(el, {\n type: props.splitType,\n smartWrap: true,\n autoSplit: props.splitType === 'lines',\n linesClass: 'split-line',\n wordsClass: 'split-word',\n charsClass: 'split-char',\n reduceWhiteSpace: false,\n onSplit(self) {\n assignTargets(self);\n\n return gsap.fromTo(\n targets,\n { ...props.from },\n {\n ...props.to,\n duration: props.duration,\n ease: props.ease,\n stagger: props.delay / 1000,\n scrollTrigger: {\n trigger: el,\n start,\n once: true,\n fastScrollEnd: true,\n anticipatePin: 0.4\n },\n onComplete() {\n animationCompleted.value = true;\n props.onLetterAnimationComplete?.();\n emit('animation-complete');\n },\n willChange: 'transform, opacity',\n force3D: true\n }\n );\n }\n });\n\n el._rbsplitInstance = splitInstance;\n};\n\nwatch(\n () => [\n props.text,\n props.delay,\n props.duration,\n props.ease,\n props.splitType,\n JSON.stringify(props.from),\n JSON.stringify(props.to),\n props.threshold,\n props.rootMargin,\n fontsLoaded.value\n ],\n runAnimation,\n { deep: true }\n);\n\nonBeforeUnmount(() => {\n ScrollTrigger.getAll().forEach(st => {\n if (st.trigger === elRef.value) st.kill();\n });\n\n try {\n splitInstance?.revert();\n } catch {}\n});\n\nconst styles = computed(() => ({\n textAlign: props.textAlign,\n wordWrap: 'break-word',\n willChange: 'transform, opacity'\n}));\n\nconst classes = computed(() => `split-parent overflow-hidden inline-block whitespace-normal ${props.className}`);\n</script>\n","path":"SplitText/SplitText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["TextAnimations"]} |