refactor BlurText with better handling, best practices, fix animation complete handler

This commit is contained in:
David Haz
2025-07-18 20:55:58 +03:00
parent c816095e52
commit f18199079f

View File

@@ -1,17 +1,42 @@
<template>
<p ref="rootRef" :class="['blur-text', className, 'flex', 'flex-wrap']">
<Motion
v-for="(segment, index) in elements"
:key="`${animationKey}-${index}`"
tag="span"
:initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:transition="getTransition(index)"
:style="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}"
@animation-complete="() => handleAnimationComplete(index)"
>
{{ segment === ' ' ? '\u00A0' : segment
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
</Motion>
</p>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
import { Motion } from 'motion-v'; import { Motion } from 'motion-v';
type AnimateBy = 'words' | 'letters';
type Direction = 'top' | 'bottom';
type AnimationSnapshot = Record<string, string | number>;
interface BlurTextProps { interface BlurTextProps {
text?: string; text?: string;
delay?: number; delay?: number;
className?: string; className?: string;
animateBy?: 'words' | 'letters'; animateBy?: AnimateBy;
direction?: 'top' | 'bottom'; direction?: Direction;
threshold?: number; threshold?: number;
rootMargin?: string; rootMargin?: string;
animationFrom?: Record<string, string | number>; animationFrom?: AnimationSnapshot;
animationTo?: Array<Record<string, string | number>>; animationTo?: AnimationSnapshot[];
easing?: (t: number) => number; easing?: (t: number) => number;
onAnimationComplete?: () => void; onAnimationComplete?: () => void;
stepDuration?: number; stepDuration?: number;
@@ -21,8 +46,8 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
text: '', text: '',
delay: 200, delay: 200,
className: '', className: '',
animateBy: 'words', animateBy: 'words' as AnimateBy,
direction: 'top', direction: 'top' as Direction,
threshold: 0.1, threshold: 0.1,
rootMargin: '0px', rootMargin: '0px',
easing: (t: number) => t, easing: (t: number) => t,
@@ -30,35 +55,37 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
}); });
const buildKeyframes = ( const buildKeyframes = (
from: Record<string, string | number>, from: AnimationSnapshot,
steps: Array<Record<string, string | number>> steps: AnimationSnapshot[]
): Record<string, Array<string | number>> => { ): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]); const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(step => Object.keys(step))]);
const keyframes: Record<string, Array<string | number>> = {}; const keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])]; for (const key of keys) {
}); keyframes[key] = [from[key], ...steps.map(step => step[key])];
}
return keyframes; return keyframes;
}; };
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split(''))); const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const inView = ref(false); const defaultFrom = computed<AnimationSnapshot>(() =>
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const defaultFrom = computed(() =>
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 } props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
); );
const defaultTo = computed(() => [ const defaultTo = computed<AnimationSnapshot[]>(() => [
{ {
filter: 'blur(5px)', filter: 'blur(5px)',
opacity: 0.5, opacity: 0.5,
y: props.direction === 'top' ? 5 : -5 y: props.direction === 'top' ? 5 : -5
}, },
{ filter: 'blur(0px)', opacity: 1, y: 0 } {
filter: 'blur(0px)',
opacity: 1,
y: 0
}
]); ]);
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value); const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value);
@@ -70,6 +97,13 @@ const times = computed(() =>
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1))) Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
); );
const inView = ref(false);
const animationKey = ref(0);
const completionFired = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const setupObserver = () => { const setupObserver = () => {
if (!rootRef.value) return; if (!rootRef.value) return;
@@ -80,25 +114,15 @@ const setupObserver = () => {
observer?.unobserve(rootRef.value as Element); observer?.unobserve(rootRef.value as Element);
} }
}, },
{ threshold: props.threshold, rootMargin: props.rootMargin } {
threshold: props.threshold,
rootMargin: props.rootMargin
}
); );
observer.observe(rootRef.value); observer.observe(rootRef.value);
}; };
onMounted(() => {
setupObserver();
});
onUnmounted(() => {
observer?.disconnect();
});
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect();
setupObserver();
});
const getAnimateKeyframes = () => { const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value); return buildKeyframes(fromSnapshot.value, toSnapshots.value);
}; };
@@ -113,29 +137,27 @@ const getTransition = (index: number) => {
}; };
const handleAnimationComplete = (index: number) => { const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && props.onAnimationComplete) { if (index === elements.value.length - 1 && !completionFired.value && props.onAnimationComplete) {
completionFired.value = true;
props.onAnimationComplete(); props.onAnimationComplete();
} }
}; };
</script>
<template> onMounted(() => {
<p ref="rootRef" :class="`blur-text ${className} flex flex-wrap`"> setupObserver();
<Motion });
v-for="(segment, index) in elements"
:key="index" onUnmounted(() => {
tag="span" observer?.disconnect();
:initial="fromSnapshot" });
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:transition="getTransition(index)" watch([() => props.threshold, () => props.rootMargin], () => {
:style="{ observer?.disconnect();
display: 'inline-block', setupObserver();
willChange: 'transform, filter, opacity' });
}"
@animation-complete="handleAnimationComplete(index)" watch([() => props.delay, () => props.stepDuration, () => props.animateBy, () => props.direction], () => {
> animationKey.value++;
{{ segment === ' ' ? '\u00A0' : segment completionFired.value = false;
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }} });
</Motion> </script>
</p>
</template>