9 changed files with 1040 additions and 429 deletions
@ -0,0 +1,131 @@ |
|||
<template> |
|||
<p ref="rootRef" class="flex flex-wrap blur-text" :class="className"> |
|||
<Motion |
|||
v-for="(segment, index) in elements" |
|||
:key="index" |
|||
tag="span" |
|||
:initial="fromSnapshot" |
|||
:animate="inView ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot" |
|||
:transition="getTransition(index)" |
|||
@animation-complete="handleAnimationComplete(index)" |
|||
style="display: inline-block; will-change: transform, filter, opacity" |
|||
> |
|||
{{ segment === ' ' ? '\u00A0' : segment }} |
|||
<template v-if="animateBy === 'words' && index < elements.length - 1"> </template> |
|||
</Motion> |
|||
</p> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { Motion, type Transition } from 'motion-v'; |
|||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'; |
|||
|
|||
type BlurTextProps = { |
|||
text?: string; |
|||
delay?: number; |
|||
className?: string; |
|||
animateBy?: 'words' | 'letters'; |
|||
direction?: 'top' | 'bottom'; |
|||
threshold?: number; |
|||
rootMargin?: string; |
|||
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', |
|||
direction: 'top', |
|||
threshold: 0.1, |
|||
rootMargin: '0px', |
|||
easing: (t: number) => t, |
|||
stepDuration: 0.35 |
|||
}); |
|||
|
|||
const inView = ref(false); |
|||
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef'); |
|||
let observer: IntersectionObserver | null = null; |
|||
|
|||
onMounted(() => { |
|||
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); |
|||
}); |
|||
|
|||
onBeforeUnmount(() => { |
|||
observer?.disconnect(); |
|||
}); |
|||
|
|||
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split(''))); |
|||
|
|||
const defaultFrom = computed(() => |
|||
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 } |
|||
); |
|||
|
|||
const defaultTo = computed(() => [ |
|||
{ |
|||
filter: 'blur(5px)', |
|||
opacity: 0.5, |
|||
y: props.direction === 'top' ? 5 : -5 |
|||
}, |
|||
{ |
|||
filter: 'blur(0px)', |
|||
opacity: 1, |
|||
y: 0 |
|||
} |
|||
]); |
|||
|
|||
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value); |
|||
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 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) { |
|||
props.onAnimationComplete?.(); |
|||
} |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,221 @@ |
|||
<script setup lang="ts"> |
|||
import { computed, onBeforeUnmount, onMounted, useTemplateRef, watch, type CSSProperties } from 'vue'; |
|||
|
|||
type ElectricBorderProps = { |
|||
color?: string; |
|||
speed?: number; |
|||
chaos?: number; |
|||
thickness?: number; |
|||
className?: string; |
|||
style?: CSSProperties; |
|||
}; |
|||
|
|||
const props = withDefaults(defineProps<ElectricBorderProps>(), { |
|||
color: '#28FF85', |
|||
speed: 1, |
|||
chaos: 1, |
|||
thickness: 2 |
|||
}); |
|||
|
|||
function hexToRgba(hex: string, alpha = 1): string { |
|||
if (!hex) return `rgba(0,0,0,${alpha})`; |
|||
let h = hex.replace('#', ''); |
|||
if (h.length === 3) { |
|||
h = h |
|||
.split('') |
|||
.map(c => c + c) |
|||
.join(''); |
|||
} |
|||
const int = parseInt(h, 16); |
|||
const r = (int >> 16) & 255; |
|||
const g = (int >> 8) & 255; |
|||
const b = int & 255; |
|||
return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
|||
} |
|||
|
|||
const rawId = `id-${crypto.randomUUID().replace(/[:]/g, '')}`; |
|||
const filterId = `turbulent-displace-${rawId}`; |
|||
|
|||
const svgRef = useTemplateRef('svgRef'); |
|||
const rootRef = useTemplateRef('rootRef'); |
|||
const strokeRef = useTemplateRef('strokeRef'); |
|||
|
|||
const updateAnim = () => { |
|||
const svg = svgRef.value; |
|||
const host = rootRef.value; |
|||
if (!svg || !host) return; |
|||
|
|||
if (strokeRef.value) { |
|||
strokeRef.value.style.filter = `url(#${filterId})`; |
|||
} |
|||
|
|||
const width = Math.max(1, Math.round(host.clientWidth || host.getBoundingClientRect().width || 0)); |
|||
const height = Math.max(1, Math.round(host.clientHeight || host.getBoundingClientRect().height || 0)); |
|||
|
|||
const dyAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dy"]')) as SVGAnimateElement[]; |
|||
if (dyAnims.length >= 2) { |
|||
dyAnims[0].setAttribute('values', `${height}; 0`); |
|||
dyAnims[1].setAttribute('values', `0; -${height}`); |
|||
} |
|||
|
|||
const dxAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dx"]')) as SVGAnimateElement[]; |
|||
if (dxAnims.length >= 2) { |
|||
dxAnims[0].setAttribute('values', `${width}; 0`); |
|||
dxAnims[1].setAttribute('values', `0; -${width}`); |
|||
} |
|||
|
|||
const baseDur = 6; |
|||
const dur = Math.max(0.001, baseDur / (props.speed || 1)); |
|||
[...dyAnims, ...dxAnims].forEach(a => a.setAttribute('dur', `${dur}s`)); |
|||
|
|||
const disp = svg.querySelector('feDisplacementMap'); |
|||
if (disp) disp.setAttribute('scale', String(30 * (props.chaos || 1))); |
|||
|
|||
const filterEl = svg.querySelector<SVGFilterElement>(`#${CSS.escape(filterId)}`); |
|||
if (filterEl) { |
|||
filterEl.setAttribute('x', '-200%'); |
|||
filterEl.setAttribute('y', '-200%'); |
|||
filterEl.setAttribute('width', '500%'); |
|||
filterEl.setAttribute('height', '500%'); |
|||
} |
|||
|
|||
requestAnimationFrame(() => { |
|||
[...dyAnims, ...dxAnims].forEach((a: SVGAnimateElement) => { |
|||
if (typeof a.beginElement === 'function') { |
|||
try { |
|||
a.beginElement(); |
|||
} catch {} |
|||
} |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
watch( |
|||
() => [props.speed, props.chaos], |
|||
() => { |
|||
updateAnim(); |
|||
}, |
|||
{ deep: true } |
|||
); |
|||
|
|||
let ro: ResizeObserver | null = null; |
|||
|
|||
onMounted(() => { |
|||
if (!rootRef.value) return; |
|||
ro = new ResizeObserver(() => updateAnim()); |
|||
ro.observe(rootRef.value); |
|||
updateAnim(); |
|||
}); |
|||
|
|||
onBeforeUnmount(() => { |
|||
if (ro) ro.disconnect(); |
|||
}); |
|||
|
|||
const inheritRadius = computed<CSSProperties>(() => { |
|||
const radius = props.style?.borderRadius; |
|||
|
|||
if (radius === undefined) { |
|||
return { borderRadius: 'inherit' }; |
|||
} |
|||
|
|||
if (typeof radius === 'number') { |
|||
return { borderRadius: `${radius}px` }; |
|||
} |
|||
|
|||
return { borderRadius: radius }; |
|||
}); |
|||
|
|||
const strokeStyle = computed<CSSProperties>(() => ({ |
|||
...inheritRadius.value, |
|||
borderWidth: `${props.thickness}px`, |
|||
borderStyle: 'solid', |
|||
borderColor: props.color |
|||
})); |
|||
|
|||
const glow1Style = computed<CSSProperties>(() => ({ |
|||
...inheritRadius.value, |
|||
borderWidth: `${props.thickness}px`, |
|||
borderStyle: 'solid', |
|||
borderColor: hexToRgba(props.color, 0.6), |
|||
filter: `blur(${0.5 + props.thickness * 0.25}px)`, |
|||
opacity: 0.5 |
|||
})); |
|||
|
|||
const glow2Style = computed<CSSProperties>(() => ({ |
|||
...inheritRadius.value, |
|||
borderWidth: `${props.thickness}px`, |
|||
borderStyle: 'solid', |
|||
borderColor: props.color, |
|||
filter: `blur(${2 + props.thickness * 0.5}px)`, |
|||
opacity: 0.5 |
|||
})); |
|||
|
|||
const bgGlowStyle = computed<CSSProperties>(() => ({ |
|||
...inheritRadius.value, |
|||
transform: 'scale(1.08)', |
|||
filter: 'blur(32px)', |
|||
opacity: 0.3, |
|||
zIndex: -1, |
|||
background: `linear-gradient(-30deg, ${hexToRgba(props.color, 0.8)}, transparent, ${props.color})` |
|||
})); |
|||
</script> |
|||
|
|||
<template> |
|||
<div ref="rootRef" :class="['relative isolate', className]" :style="style"> |
|||
<svg |
|||
ref="svgRef" |
|||
class="fixed opacity-0 w-0 h-0 pointer-events-none" |
|||
style="position: absolute; top: -9999px; left: -9999px" |
|||
aria-hidden="true" |
|||
focusable="false" |
|||
> |
|||
<defs> |
|||
<filter :id="filterId" color-interpolation-filters="sRGB" x="-200%" y="-200%" width="500%" height="500%"> |
|||
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise1" seed="1" /> |
|||
<feOffset in="noise1" dx="0" dy="0" result="offsetNoise1"> |
|||
<animate attributeName="dy" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" /> |
|||
</feOffset> |
|||
|
|||
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise2" seed="3" /> |
|||
<feOffset in="noise2" dx="0" dy="0" result="offsetNoise2"> |
|||
<animate attributeName="dy" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" /> |
|||
</feOffset> |
|||
|
|||
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise3" seed="5" /> |
|||
<feOffset in="noise3" dx="0" dy="0" result="offsetNoise3"> |
|||
<animate attributeName="dx" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" /> |
|||
</feOffset> |
|||
|
|||
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise4" seed="7" /> |
|||
<feOffset in="noise4" dx="0" dy="0" result="offsetNoise4"> |
|||
<animate attributeName="dx" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" /> |
|||
</feOffset> |
|||
|
|||
<feComposite in="offsetNoise1" in2="offsetNoise2" operator="add" result="verticalNoise" /> |
|||
<feComposite in="offsetNoise3" in2="offsetNoise4" operator="add" result="horizontalNoise" /> |
|||
<feBlend in="verticalNoise" in2="horizontalNoise" mode="screen" result="combinedNoise" /> |
|||
|
|||
<feDisplacementMap |
|||
in="SourceGraphic" |
|||
in2="combinedNoise" |
|||
scale="30" |
|||
xChannelSelector="R" |
|||
yChannelSelector="G" |
|||
result="displaced" |
|||
/> |
|||
</filter> |
|||
</defs> |
|||
</svg> |
|||
|
|||
<div class="absolute inset-0 pointer-events-none" :style="inheritRadius"> |
|||
<div ref="strokeRef" class="box-border absolute inset-0" :style="strokeStyle" /> |
|||
<div class="box-border absolute inset-0" :style="glow1Style" /> |
|||
<div class="box-border absolute inset-0" :style="glow2Style" /> |
|||
<div class="absolute inset-0" :style="bgGlowStyle" /> |
|||
</div> |
|||
|
|||
<div class="relative" :style="inheritRadius"> |
|||
<slot /> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,455 @@ |
|||
<template> |
|||
<component :is="tag" ref="textRef" :class="computedClasses" :style="computedStyle"> |
|||
{{ text }} |
|||
</component> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue'; |
|||
import { gsap } from 'gsap'; |
|||
import { ScrollTrigger } from 'gsap/ScrollTrigger'; |
|||
import { SplitText as GSAPSplitText } from 'gsap/SplitText'; |
|||
|
|||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText); |
|||
|
|||
export interface ShuffleProps { |
|||
text: string; |
|||
className?: string; |
|||
style?: Record<string, any>; |
|||
shuffleDirection?: 'left' | 'right' | 'up' | 'down'; |
|||
duration?: number; |
|||
maxDelay?: number; |
|||
ease?: string | ((t: number) => number); |
|||
threshold?: number; |
|||
rootMargin?: string; |
|||
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span'; |
|||
textAlign?: 'left' | 'center' | 'right' | 'justify'; |
|||
onShuffleComplete?: () => void; |
|||
shuffleTimes?: number; |
|||
animationMode?: 'random' | 'evenodd'; |
|||
loop?: boolean; |
|||
loopDelay?: number; |
|||
stagger?: number; |
|||
scrambleCharset?: string; |
|||
colorFrom?: string; |
|||
colorTo?: string; |
|||
triggerOnce?: boolean; |
|||
respectReducedMotion?: boolean; |
|||
triggerOnHover?: boolean; |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<ShuffleProps>(), { |
|||
className: '', |
|||
shuffleDirection: 'right', |
|||
duration: 0.35, |
|||
maxDelay: 0, |
|||
ease: 'power3.out', |
|||
threshold: 0.1, |
|||
rootMargin: '-100px', |
|||
tag: 'p', |
|||
textAlign: 'center', |
|||
shuffleTimes: 1, |
|||
animationMode: 'evenodd', |
|||
loop: false, |
|||
loopDelay: 0, |
|||
stagger: 0.03, |
|||
scrambleCharset: '', |
|||
colorFrom: undefined, |
|||
colorTo: undefined, |
|||
triggerOnce: true, |
|||
respectReducedMotion: true, |
|||
triggerOnHover: true |
|||
}); |
|||
|
|||
const emit = defineEmits<{ |
|||
'shuffle-complete': []; |
|||
}>(); |
|||
|
|||
const textRef = useTemplateRef<HTMLElement>('textRef'); |
|||
const fontsLoaded = ref(false); |
|||
const ready = ref(false); |
|||
|
|||
const splitRef = ref<GSAPSplitText | null>(null); |
|||
const wrappersRef = ref<HTMLElement[]>([]); |
|||
const tlRef = ref<gsap.core.Timeline | null>(null); |
|||
const playingRef = ref(false); |
|||
const scrollTriggerRef = ref<ScrollTrigger | null>(null); |
|||
let hoverHandler: ((e: Event) => void) | null = null; |
|||
|
|||
const scrollTriggerStart = computed(() => { |
|||
const startPct = (1 - props.threshold) * 100; |
|||
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || ''); |
|||
const mv = mm ? parseFloat(mm[1]) : 0; |
|||
const mu = mm ? mm[2] || 'px' : 'px'; |
|||
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`; |
|||
return `top ${startPct}%${sign}`; |
|||
}); |
|||
|
|||
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none'; |
|||
|
|||
const userHasFont = computed(() => props.className && /font[-[]/i.test(props.className)); |
|||
|
|||
const fallbackFont = computed(() => (userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` })); |
|||
|
|||
const computedStyle = computed(() => ({ |
|||
textAlign: props.textAlign, |
|||
...fallbackFont.value, |
|||
...props.style |
|||
})); |
|||
|
|||
const computedClasses = computed(() => `${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim()); |
|||
|
|||
const removeHover = () => { |
|||
if (hoverHandler && textRef.value) { |
|||
textRef.value.removeEventListener('mouseenter', hoverHandler); |
|||
hoverHandler = null; |
|||
} |
|||
}; |
|||
|
|||
const teardown = () => { |
|||
if (tlRef.value) { |
|||
tlRef.value.kill(); |
|||
tlRef.value = null; |
|||
} |
|||
if (wrappersRef.value.length) { |
|||
wrappersRef.value.forEach(wrap => { |
|||
const inner = wrap.firstElementChild as HTMLElement | null; |
|||
const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null; |
|||
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap); |
|||
}); |
|||
wrappersRef.value = []; |
|||
} |
|||
try { |
|||
splitRef.value?.revert(); |
|||
} catch {} |
|||
splitRef.value = null; |
|||
playingRef.value = false; |
|||
}; |
|||
|
|||
const build = () => { |
|||
if (!textRef.value) return; |
|||
teardown(); |
|||
|
|||
const el = textRef.value; |
|||
const computedFont = getComputedStyle(el).fontFamily; |
|||
|
|||
splitRef.value = new GSAPSplitText(el, { |
|||
type: 'chars', |
|||
charsClass: 'shuffle-char', |
|||
wordsClass: 'shuffle-word', |
|||
linesClass: 'shuffle-line', |
|||
reduceWhiteSpace: false |
|||
}); |
|||
|
|||
const chars = (splitRef.value.chars || []) as HTMLElement[]; |
|||
wrappersRef.value = []; |
|||
|
|||
const rolls = Math.max(1, Math.floor(props.shuffleTimes)); |
|||
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || ''; |
|||
|
|||
chars.forEach(ch => { |
|||
const parent = ch.parentElement; |
|||
if (!parent) return; |
|||
|
|||
const w = ch.getBoundingClientRect().width; |
|||
const h = ch.getBoundingClientRect().height; |
|||
if (!w) return; |
|||
|
|||
const wrap = document.createElement('span'); |
|||
wrap.className = 'inline-block overflow-hidden text-left'; |
|||
Object.assign(wrap.style, { |
|||
width: w + 'px', |
|||
height: props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? h + 'px' : 'auto', |
|||
verticalAlign: 'bottom' |
|||
}); |
|||
|
|||
const inner = document.createElement('span'); |
|||
inner.className = |
|||
'inline-block will-change-transform origin-left transform-gpu ' + |
|||
(props.shuffleDirection === 'up' || props.shuffleDirection === 'down' |
|||
? 'whitespace-normal' |
|||
: 'whitespace-nowrap'); |
|||
|
|||
parent.insertBefore(wrap, ch); |
|||
wrap.appendChild(inner); |
|||
|
|||
const firstOrig = ch.cloneNode(true) as HTMLElement; |
|||
firstOrig.className = |
|||
'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block'); |
|||
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont }); |
|||
|
|||
ch.setAttribute('data-orig', '1'); |
|||
ch.className = |
|||
'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block'); |
|||
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont }); |
|||
|
|||
inner.appendChild(firstOrig); |
|||
for (let k = 0; k < rolls; k++) { |
|||
const c = ch.cloneNode(true) as HTMLElement; |
|||
if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset); |
|||
c.className = |
|||
'text-left ' + |
|||
(props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block'); |
|||
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont }); |
|||
inner.appendChild(c); |
|||
} |
|||
inner.appendChild(ch); |
|||
|
|||
const steps = rolls + 1; |
|||
if (props.shuffleDirection === 'right' || props.shuffleDirection === 'down') { |
|||
const firstCopy = inner.firstElementChild as HTMLElement | null; |
|||
const real = inner.lastElementChild as HTMLElement | null; |
|||
if (real) inner.insertBefore(real, inner.firstChild); |
|||
if (firstCopy) inner.appendChild(firstCopy); |
|||
} |
|||
|
|||
let startX = 0; |
|||
let finalX = 0; |
|||
let startY = 0; |
|||
let finalY = 0; |
|||
|
|||
if (props.shuffleDirection === 'right') { |
|||
startX = -steps * w; |
|||
finalX = 0; |
|||
} else if (props.shuffleDirection === 'left') { |
|||
startX = 0; |
|||
finalX = -steps * w; |
|||
} else if (props.shuffleDirection === 'down') { |
|||
startY = -steps * h; |
|||
finalY = 0; |
|||
} else if (props.shuffleDirection === 'up') { |
|||
startY = 0; |
|||
finalY = -steps * h; |
|||
} |
|||
|
|||
if (props.shuffleDirection === 'left' || props.shuffleDirection === 'right') { |
|||
gsap.set(inner, { x: startX, y: 0, force3D: true }); |
|||
inner.setAttribute('data-start-x', String(startX)); |
|||
inner.setAttribute('data-final-x', String(finalX)); |
|||
} else { |
|||
gsap.set(inner, { x: 0, y: startY, force3D: true }); |
|||
inner.setAttribute('data-start-y', String(startY)); |
|||
inner.setAttribute('data-final-y', String(finalY)); |
|||
} |
|||
|
|||
if (props.colorFrom) (inner.style as any).color = props.colorFrom; |
|||
wrappersRef.value.push(wrap); |
|||
}); |
|||
}; |
|||
|
|||
const getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement); |
|||
|
|||
const randomizeScrambles = () => { |
|||
if (!props.scrambleCharset) return; |
|||
wrappersRef.value.forEach(w => { |
|||
const strip = w.firstElementChild as HTMLElement; |
|||
if (!strip) return; |
|||
const kids = Array.from(strip.children) as HTMLElement[]; |
|||
for (let i = 1; i < kids.length - 1; i++) { |
|||
kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length)); |
|||
} |
|||
}); |
|||
}; |
|||
|
|||
const cleanupToStill = () => { |
|||
wrappersRef.value.forEach(w => { |
|||
const strip = w.firstElementChild as HTMLElement; |
|||
if (!strip) return; |
|||
const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null; |
|||
if (!real) return; |
|||
strip.replaceChildren(real); |
|||
strip.style.transform = 'none'; |
|||
strip.style.willChange = 'auto'; |
|||
}); |
|||
}; |
|||
|
|||
const armHover = () => { |
|||
if (!props.triggerOnHover || !textRef.value) return; |
|||
removeHover(); |
|||
const handler = () => { |
|||
if (playingRef.value) return; |
|||
build(); |
|||
if (props.scrambleCharset) randomizeScrambles(); |
|||
play(); |
|||
}; |
|||
hoverHandler = handler; |
|||
textRef.value.addEventListener('mouseenter', handler); |
|||
}; |
|||
|
|||
const play = () => { |
|||
const strips = getInners(); |
|||
if (!strips.length) return; |
|||
|
|||
playingRef.value = true; |
|||
const isVertical = props.shuffleDirection === 'up' || props.shuffleDirection === 'down'; |
|||
|
|||
const tl = gsap.timeline({ |
|||
smoothChildTiming: true, |
|||
repeat: props.loop ? -1 : 0, |
|||
repeatDelay: props.loop ? props.loopDelay : 0, |
|||
onRepeat: () => { |
|||
if (props.scrambleCharset) randomizeScrambles(); |
|||
if (isVertical) { |
|||
gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') }); |
|||
} else { |
|||
gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') }); |
|||
} |
|||
emit('shuffle-complete'); |
|||
props.onShuffleComplete?.(); |
|||
}, |
|||
onComplete: () => { |
|||
playingRef.value = false; |
|||
if (!props.loop) { |
|||
cleanupToStill(); |
|||
if (props.colorTo) gsap.set(strips, { color: props.colorTo }); |
|||
emit('shuffle-complete'); |
|||
props.onShuffleComplete?.(); |
|||
armHover(); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
const addTween = (targets: HTMLElement[], at: number) => { |
|||
const vars: any = { |
|||
duration: props.duration, |
|||
ease: props.ease, |
|||
force3D: true, |
|||
stagger: props.animationMode === 'evenodd' ? props.stagger : 0 |
|||
}; |
|||
if (isVertical) { |
|||
vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0'); |
|||
} else { |
|||
vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0'); |
|||
} |
|||
|
|||
tl.to(targets, vars, at); |
|||
if (props.colorFrom && props.colorTo) |
|||
tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at); |
|||
}; |
|||
|
|||
if (props.animationMode === 'evenodd') { |
|||
const odd = strips.filter((_, i) => i % 2 === 1); |
|||
const even = strips.filter((_, i) => i % 2 === 0); |
|||
const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger; |
|||
const evenStart = odd.length ? oddTotal * 0.7 : 0; |
|||
if (odd.length) addTween(odd, 0); |
|||
if (even.length) addTween(even, evenStart); |
|||
} else { |
|||
strips.forEach(strip => { |
|||
const d = Math.random() * props.maxDelay; |
|||
const vars: any = { |
|||
duration: props.duration, |
|||
ease: props.ease, |
|||
force3D: true |
|||
}; |
|||
if (isVertical) { |
|||
vars.y = parseFloat(strip.getAttribute('data-final-y') || '0'); |
|||
} else { |
|||
vars.x = parseFloat(strip.getAttribute('data-final-x') || '0'); |
|||
} |
|||
tl.to(strip, vars, d); |
|||
if (props.colorFrom && props.colorTo) |
|||
tl.fromTo( |
|||
strip, |
|||
{ color: props.colorFrom }, |
|||
{ color: props.colorTo, duration: props.duration, ease: props.ease }, |
|||
d |
|||
); |
|||
}); |
|||
} |
|||
|
|||
tlRef.value = tl; |
|||
}; |
|||
|
|||
const create = () => { |
|||
build(); |
|||
if (props.scrambleCharset) randomizeScrambles(); |
|||
play(); |
|||
armHover(); |
|||
ready.value = true; |
|||
}; |
|||
|
|||
const initializeAnimation = async () => { |
|||
if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return; |
|||
|
|||
if ( |
|||
props.respectReducedMotion && |
|||
window.matchMedia && |
|||
window.matchMedia('(prefers-reduced-motion: reduce)').matches |
|||
) { |
|||
ready.value = true; |
|||
emit('shuffle-complete'); |
|||
props.onShuffleComplete?.(); |
|||
return; |
|||
} |
|||
|
|||
await nextTick(); |
|||
|
|||
const el = textRef.value; |
|||
const start = scrollTriggerStart.value; |
|||
|
|||
const st = ScrollTrigger.create({ |
|||
trigger: el, |
|||
start, |
|||
once: props.triggerOnce, |
|||
onEnter: create |
|||
}); |
|||
|
|||
scrollTriggerRef.value = st; |
|||
}; |
|||
|
|||
const cleanup = () => { |
|||
if (scrollTriggerRef.value) { |
|||
scrollTriggerRef.value.kill(); |
|||
scrollTriggerRef.value = null; |
|||
} |
|||
removeHover(); |
|||
teardown(); |
|||
ready.value = false; |
|||
}; |
|||
|
|||
onMounted(async () => { |
|||
if ('fonts' in document) { |
|||
if (document.fonts.status === 'loaded') { |
|||
fontsLoaded.value = true; |
|||
} else { |
|||
await document.fonts.ready; |
|||
fontsLoaded.value = true; |
|||
} |
|||
} else { |
|||
fontsLoaded.value = true; |
|||
} |
|||
|
|||
initializeAnimation(); |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
cleanup(); |
|||
}); |
|||
|
|||
watch( |
|||
[ |
|||
() => props.text, |
|||
() => props.duration, |
|||
() => props.maxDelay, |
|||
() => props.ease, |
|||
() => props.shuffleDirection, |
|||
() => props.shuffleTimes, |
|||
() => props.animationMode, |
|||
() => props.loop, |
|||
() => props.loopDelay, |
|||
() => props.stagger, |
|||
() => props.scrambleCharset, |
|||
() => props.colorFrom, |
|||
() => props.colorTo, |
|||
() => props.triggerOnce, |
|||
() => props.respectReducedMotion, |
|||
() => props.triggerOnHover, |
|||
() => fontsLoaded.value |
|||
], |
|||
() => { |
|||
cleanup(); |
|||
initializeAnimation(); |
|||
} |
|||
); |
|||
</script> |
|||
Loading…
Reference in new issue