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

1 line
14 KiB
JSON

{"name":"Shuffle","title":"Shuffle","description":"GSAP-powered slot machine style text shuffle animation with scroll trigger.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <component :is=\"tag\" ref=\"textRef\" :class=\"computedClasses\" :style=\"computedStyle\">\n {{ text }}\n </component>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, 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 ShuffleProps {\n text: string;\n className?: string;\n style?: Record<string, any>;\n shuffleDirection?: 'left' | 'right' | 'up' | 'down';\n duration?: number;\n maxDelay?: number;\n ease?: string | ((t: number) => number);\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: 'left' | 'center' | 'right' | 'justify';\n onShuffleComplete?: () => void;\n shuffleTimes?: number;\n animationMode?: 'random' | 'evenodd';\n loop?: boolean;\n loopDelay?: number;\n stagger?: number;\n scrambleCharset?: string;\n colorFrom?: string;\n colorTo?: string;\n triggerOnce?: boolean;\n respectReducedMotion?: boolean;\n triggerOnHover?: boolean;\n}\n\nconst props = withDefaults(defineProps<ShuffleProps>(), {\n className: '',\n shuffleDirection: 'right',\n duration: 0.35,\n maxDelay: 0,\n ease: 'power3.out',\n threshold: 0.1,\n rootMargin: '-100px',\n tag: 'p',\n textAlign: 'center',\n shuffleTimes: 1,\n animationMode: 'evenodd',\n loop: false,\n loopDelay: 0,\n stagger: 0.03,\n scrambleCharset: '',\n colorFrom: undefined,\n colorTo: undefined,\n triggerOnce: true,\n respectReducedMotion: true,\n triggerOnHover: true\n});\n\nconst emit = defineEmits<{\n 'shuffle-complete': [];\n}>();\n\nconst textRef = useTemplateRef<HTMLElement>('textRef');\nconst fontsLoaded = ref(false);\nconst ready = ref(false);\n\nconst splitRef = ref<GSAPSplitText | null>(null);\nconst wrappersRef = ref<HTMLElement[]>([]);\nconst tlRef = ref<gsap.core.Timeline | null>(null);\nconst playingRef = ref(false);\nconst scrollTriggerRef = ref<ScrollTrigger | null>(null);\nlet hoverHandler: ((e: Event) => void) | null = null;\n\nconst scrollTriggerStart = computed(() => {\n const startPct = (1 - props.threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n});\n\nconst baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none';\n\nconst userHasFont = computed(() => props.className && /font[-[]/i.test(props.className));\n\nconst fallbackFont = computed(() => (userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` }));\n\nconst computedStyle = computed(() => ({\n textAlign: props.textAlign,\n ...fallbackFont.value,\n ...props.style\n}));\n\nconst computedClasses = computed(() => `${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim());\n\nconst removeHover = () => {\n if (hoverHandler && textRef.value) {\n textRef.value.removeEventListener('mouseenter', hoverHandler);\n hoverHandler = null;\n }\n};\n\nconst teardown = () => {\n if (tlRef.value) {\n tlRef.value.kill();\n tlRef.value = null;\n }\n if (wrappersRef.value.length) {\n wrappersRef.value.forEach(wrap => {\n const inner = wrap.firstElementChild as HTMLElement | null;\n const orig = inner?.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.value = [];\n }\n try {\n splitRef.value?.revert();\n } catch {}\n splitRef.value = null;\n playingRef.value = false;\n};\n\nconst build = () => {\n if (!textRef.value) return;\n teardown();\n\n const el = textRef.value;\n const computedFont = getComputedStyle(el).fontFamily;\n\n splitRef.value = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n reduceWhiteSpace: false\n });\n\n const chars = (splitRef.value.chars || []) as HTMLElement[];\n wrappersRef.value = [];\n\n const rolls = Math.max(1, Math.floor(props.shuffleTimes));\n const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const wrap = document.createElement('span');\n wrap.className = 'inline-block overflow-hidden text-left';\n Object.assign(wrap.style, {\n width: w + 'px',\n height: props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? h + 'px' : 'auto',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n inner.className =\n 'inline-block will-change-transform origin-left transform-gpu ' +\n (props.shuffleDirection === 'up' || props.shuffleDirection === 'down'\n ? 'whitespace-normal'\n : 'whitespace-nowrap');\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true) as HTMLElement;\n firstOrig.className =\n 'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });\n\n ch.setAttribute('data-orig', '1');\n ch.className =\n 'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true) as HTMLElement;\n if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset);\n c.className =\n 'text-left ' +\n (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n if (props.shuffleDirection === 'right' || props.shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild as HTMLElement | null;\n const real = inner.lastElementChild as HTMLElement | null;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (props.shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (props.shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (props.shuffleDirection === 'down') {\n startY = -steps * h;\n finalY = 0;\n } else if (props.shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * h;\n }\n\n if (props.shuffleDirection === 'left' || props.shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (props.colorFrom) (inner.style as any).color = props.colorFrom;\n wrappersRef.value.push(wrap);\n });\n};\n\nconst getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement);\n\nconst randomizeScrambles = () => {\n if (!props.scrambleCharset) return;\n wrappersRef.value.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const kids = Array.from(strip.children) as HTMLElement[];\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length));\n }\n });\n};\n\nconst cleanupToStill = () => {\n wrappersRef.value.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n};\n\nconst armHover = () => {\n if (!props.triggerOnHover || !textRef.value) return;\n removeHover();\n const handler = () => {\n if (playingRef.value) return;\n build();\n if (props.scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandler = handler;\n textRef.value.addEventListener('mouseenter', handler);\n};\n\nconst play = () => {\n const strips = getInners();\n if (!strips.length) return;\n\n playingRef.value = true;\n const isVertical = props.shuffleDirection === 'up' || props.shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: props.loop ? -1 : 0,\n repeatDelay: props.loop ? props.loopDelay : 0,\n onRepeat: () => {\n if (props.scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n emit('shuffle-complete');\n props.onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.value = false;\n if (!props.loop) {\n cleanupToStill();\n if (props.colorTo) gsap.set(strips, { color: props.colorTo });\n emit('shuffle-complete');\n props.onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets: HTMLElement[], at: number) => {\n const vars: any = {\n duration: props.duration,\n ease: props.ease,\n force3D: true,\n stagger: props.animationMode === 'evenodd' ? props.stagger : 0\n };\n if (isVertical) {\n vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n if (props.colorFrom && props.colorTo)\n tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at);\n };\n\n if (props.animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * props.maxDelay;\n const vars: any = {\n duration: props.duration,\n ease: props.ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (props.colorFrom && props.colorTo)\n tl.fromTo(\n strip,\n { color: props.colorFrom },\n { color: props.colorTo, duration: props.duration, ease: props.ease },\n d\n );\n });\n }\n\n tlRef.value = tl;\n};\n\nconst create = () => {\n build();\n if (props.scrambleCharset) randomizeScrambles();\n play();\n armHover();\n ready.value = true;\n};\n\nconst initializeAnimation = async () => {\n if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return;\n\n if (\n props.respectReducedMotion &&\n window.matchMedia &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n ) {\n ready.value = true;\n emit('shuffle-complete');\n props.onShuffleComplete?.();\n return;\n }\n\n await nextTick();\n\n const el = textRef.value;\n const start = scrollTriggerStart.value;\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: props.triggerOnce,\n onEnter: create\n });\n\n scrollTriggerRef.value = st;\n};\n\nconst cleanup = () => {\n if (scrollTriggerRef.value) {\n scrollTriggerRef.value.kill();\n scrollTriggerRef.value = null;\n }\n removeHover();\n teardown();\n ready.value = false;\n};\n\nonMounted(async () => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') {\n fontsLoaded.value = true;\n } else {\n await document.fonts.ready;\n fontsLoaded.value = true;\n }\n } else {\n fontsLoaded.value = true;\n }\n\n initializeAnimation();\n});\n\nonUnmounted(() => {\n cleanup();\n});\n\nwatch(\n [\n () => props.text,\n () => props.duration,\n () => props.maxDelay,\n () => props.ease,\n () => props.shuffleDirection,\n () => props.shuffleTimes,\n () => props.animationMode,\n () => props.loop,\n () => props.loopDelay,\n () => props.stagger,\n () => props.scrambleCharset,\n () => props.colorFrom,\n () => props.colorTo,\n () => props.triggerOnce,\n () => props.respectReducedMotion,\n () => props.triggerOnHover,\n () => fontsLoaded.value\n ],\n () => {\n cleanup();\n initializeAnimation();\n }\n);\n</script>\n","path":"Shuffle/Shuffle.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["TextAnimations"]}