mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
refactor BlurText with better handling, best practices, fix animation complete handler
This commit is contained in:
@@ -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">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
|
||||
import { Motion } from 'motion-v';
|
||||
|
||||
type AnimateBy = 'words' | 'letters';
|
||||
type Direction = 'top' | 'bottom';
|
||||
type AnimationSnapshot = Record<string, string | number>;
|
||||
|
||||
interface BlurTextProps {
|
||||
text?: string;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
animateBy?: 'words' | 'letters';
|
||||
direction?: 'top' | 'bottom';
|
||||
animateBy?: AnimateBy;
|
||||
direction?: Direction;
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
animationFrom?: Record<string, string | number>;
|
||||
animationTo?: Array<Record<string, string | number>>;
|
||||
animationFrom?: AnimationSnapshot;
|
||||
animationTo?: AnimationSnapshot[];
|
||||
easing?: (t: number) => number;
|
||||
onAnimationComplete?: () => void;
|
||||
stepDuration?: number;
|
||||
@@ -21,8 +46,8 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
|
||||
text: '',
|
||||
delay: 200,
|
||||
className: '',
|
||||
animateBy: 'words',
|
||||
direction: 'top',
|
||||
animateBy: 'words' as AnimateBy,
|
||||
direction: 'top' as Direction,
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px',
|
||||
easing: (t: number) => t,
|
||||
@@ -30,35 +55,37 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
|
||||
});
|
||||
|
||||
const buildKeyframes = (
|
||||
from: Record<string, string | number>,
|
||||
steps: Array<Record<string, string | number>>
|
||||
from: AnimationSnapshot,
|
||||
steps: AnimationSnapshot[]
|
||||
): 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>> = {};
|
||||
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;
|
||||
};
|
||||
|
||||
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
|
||||
|
||||
const inView = ref(false);
|
||||
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const defaultFrom = computed(() =>
|
||||
const defaultFrom = computed<AnimationSnapshot>(() =>
|
||||
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)',
|
||||
opacity: 0.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);
|
||||
@@ -70,6 +97,13 @@ const times = computed(() =>
|
||||
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 = () => {
|
||||
if (!rootRef.value) return;
|
||||
|
||||
@@ -80,25 +114,15 @@ const setupObserver = () => {
|
||||
observer?.unobserve(rootRef.value as Element);
|
||||
}
|
||||
},
|
||||
{ threshold: props.threshold, rootMargin: props.rootMargin }
|
||||
{
|
||||
threshold: props.threshold,
|
||||
rootMargin: props.rootMargin
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(rootRef.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
watch([() => props.threshold, () => props.rootMargin], () => {
|
||||
observer?.disconnect();
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
const getAnimateKeyframes = () => {
|
||||
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
|
||||
};
|
||||
@@ -113,29 +137,27 @@ const getTransition = (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();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p ref="rootRef" :class="`blur-text ${className} flex flex-wrap`">
|
||||
<Motion
|
||||
v-for="(segment, index) in elements"
|
||||
:key="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>
|
||||
onMounted(() => {
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
watch([() => props.threshold, () => props.rootMargin], () => {
|
||||
observer?.disconnect();
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
watch([() => props.delay, () => props.stepDuration, () => props.animateBy, () => props.direction], () => {
|
||||
animationKey.value++;
|
||||
completionFired.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user