mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
5.3 KiB
JSON
1 line
5.3 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 <p\n ref=\"textRef\"\n :class=\"`split-parent overflow-hidden inline-block whitespace-normal ${className}`\"\n :style=\"{\n textAlign,\n wordWrap: 'break-word'\n }\"\n >\n {{ text }}\n </p>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, watch, nextTick, useTemplateRef } 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' | 'words, chars';\n from?: gsap.TweenVars;\n to?: gsap.TweenVars;\n threshold?: number;\n rootMargin?: string;\n textAlign?: 'left' | 'center' | 'right' | 'justify';\n onLetterAnimationComplete?: () => void;\n}\n\nconst props = withDefaults(defineProps<SplitTextProps>(), {\n className: '',\n delay: 100,\n duration: 0.6,\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 textAlign: 'center'\n});\n\nconst emit = defineEmits<{\n 'animation-complete': [];\n}>();\n\nconst textRef = useTemplateRef<HTMLParagraphElement>('textRef');\nconst animationCompletedRef = ref(false);\nconst scrollTriggerRef = ref<ScrollTrigger | null>(null);\nconst timelineRef = ref<gsap.core.Timeline | null>(null);\nconst splitterRef = ref<GSAPSplitText | null>(null);\n\nconst initializeAnimation = async () => {\n if (typeof window === 'undefined' || !textRef.value || !props.text) return;\n\n await nextTick();\n\n const el = textRef.value;\n\n animationCompletedRef.value = false;\n\n const absoluteLines = props.splitType === 'lines';\n if (absoluteLines) el.style.position = 'relative';\n\n let splitter: GSAPSplitText;\n try {\n splitter = new GSAPSplitText(el, {\n type: props.splitType,\n absolute: absoluteLines,\n linesClass: 'split-line'\n });\n splitterRef.value = splitter;\n } catch (error) {\n console.error('Failed to create SplitText:', error);\n return;\n }\n\n let targets: Element[];\n switch (props.splitType) {\n case 'lines':\n targets = splitter.lines;\n break;\n case 'words':\n targets = splitter.words;\n break;\n case 'chars':\n targets = splitter.chars;\n break;\n default:\n targets = splitter.chars;\n }\n\n if (!targets || targets.length === 0) {\n console.warn('No targets found for SplitText animation');\n splitter.revert();\n return;\n }\n\n targets.forEach(t => {\n (t as HTMLElement).style.willChange = 'transform, opacity';\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 ? marginMatch[2] || 'px' : 'px';\n const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`;\n const start = `top ${startPct}%${sign}`;\n\n const tl = gsap.timeline({\n scrollTrigger: {\n trigger: el,\n start,\n toggleActions: 'play none none none',\n once: true,\n onToggle: self => {\n scrollTriggerRef.value = self;\n }\n },\n smoothChildTiming: true,\n onComplete: () => {\n animationCompletedRef.value = true;\n gsap.set(targets, {\n ...props.to,\n clearProps: 'willChange',\n immediateRender: true\n });\n props.onLetterAnimationComplete?.();\n emit('animation-complete');\n }\n });\n\n timelineRef.value = tl;\n\n tl.set(targets, { ...props.from, immediateRender: false, force3D: true });\n tl.to(targets, {\n ...props.to,\n duration: props.duration,\n ease: props.ease,\n stagger: props.delay / 1000,\n force3D: true\n });\n};\n\nconst cleanup = () => {\n if (timelineRef.value) {\n timelineRef.value.kill();\n timelineRef.value = null;\n }\n if (scrollTriggerRef.value) {\n scrollTriggerRef.value.kill();\n scrollTriggerRef.value = null;\n }\n if (splitterRef.value) {\n gsap.killTweensOf(textRef.value);\n splitterRef.value.revert();\n splitterRef.value = null;\n }\n};\n\nonMounted(() => {\n initializeAnimation();\n});\n\nonUnmounted(() => {\n cleanup();\n});\n\nwatch(\n [\n () => props.text,\n () => props.delay,\n () => props.duration,\n () => props.ease,\n () => props.splitType,\n () => props.from,\n () => props.to,\n () => props.threshold,\n () => props.rootMargin,\n () => props.onLetterAnimationComplete\n ],\n () => {\n cleanup();\n initializeAnimation();\n }\n);\n</script>\n","path":"SplitText/SplitText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["TextAnimations"]} |