mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -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">
|
<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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user