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