[ REFACT ] : BlurText Text Animation

This commit is contained in:
Utkarsh-Singhal-26
2026-01-31 22:58:03 +05:30
parent d312302823
commit 101dde4c6d
3 changed files with 110 additions and 153 deletions

View File

@@ -1,18 +1,15 @@
import code from '@content/TextAnimations/BlurText/BlurText.vue?raw';
import { createCodeObject } from '@/types/code'; import { createCodeObject } from '@/types/code';
import code from '@content/TextAnimations/BlurText/BlurText.vue?raw';
export const blurText = createCodeObject(code, 'TextAnimations/BlurText', { export const blurText = createCodeObject(code, 'TextAnimations/BlurText', {
installation: `npm install motion-v`, installation: `npm install motion-v`,
usage: `<template> usage: `<template>
<BlurText <BlurText
text="Isn't this so cool?!" text="Isn't this so cool?!"
:delay="200"
class-name="text-2xl font-semibold text-center"
animate-by="words" animate-by="words"
direction="top" direction="top"
:threshold="0.1" :delay="200"
root-margin="0px" class-name="text-2xl font-semibold text-center"
:step-duration="0.35"
@animation-complete="handleAnimationComplete" @animation-complete="handleAnimationComplete"
/> />
</template> </template>

View File

@@ -1,81 +1,99 @@
<template> <template>
<p ref="rootRef" :class="['blur-text', className, 'flex', 'flex-wrap']"> <p ref="rootRef" class="flex flex-wrap blur-text" :class="className">
<Motion <Motion
v-for="(segment, index) in elements" v-for="(segment, index) in elements"
:key="`${animationKey}-${index}`" :key="index"
tag="span" tag="span"
:initial="fromSnapshot" :initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot" :animate="inView ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot"
:transition="getTransition(index)" :transition="getTransition(index)"
:style="{ @animation-complete="handleAnimationComplete(index)"
display: 'inline-block', style="display: inline-block; will-change: transform, filter, opacity"
willChange: 'transform, filter, opacity'
}"
@animation-complete="() => handleAnimationComplete(index)"
> >
{{ segment === ' ' ? '\u00A0' : segment {{ segment === ' ' ? '\u00A0' : segment }}
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }} <template v-if="animateBy === 'words' && index < elements.length - 1">&nbsp;</template>
</Motion> </Motion>
</p> </p>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue'; import { Motion, type Transition } from 'motion-v';
import { Motion } from 'motion-v'; import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
type AnimateBy = 'words' | 'letters'; type BlurTextProps = {
type Direction = 'top' | 'bottom';
type AnimationSnapshot = Record<string, string | number>;
interface BlurTextProps {
text?: string; text?: string;
delay?: number; delay?: number;
className?: string; className?: string;
animateBy?: AnimateBy; animateBy?: 'words' | 'letters';
direction?: Direction; direction?: 'top' | 'bottom';
threshold?: number; threshold?: number;
rootMargin?: string; rootMargin?: string;
animationFrom?: AnimationSnapshot; animationFrom?: Record<string, string | number>;
animationTo?: AnimationSnapshot[]; animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number; easing?: (t: number) => number;
onAnimationComplete?: () => void; onAnimationComplete?: () => void;
stepDuration?: number; 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>(), { const props = withDefaults(defineProps<BlurTextProps>(), {
text: '', text: '',
delay: 200, delay: 200,
className: '', className: '',
animateBy: 'words' as AnimateBy, animateBy: 'words',
direction: 'top' as Direction, direction: 'top',
threshold: 0.1, threshold: 0.1,
rootMargin: '0px', rootMargin: '0px',
easing: (t: number) => t, easing: (t: number) => t,
stepDuration: 0.35 stepDuration: 0.35
}); });
const buildKeyframes = ( const inView = ref(false);
from: AnimationSnapshot, const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
steps: AnimationSnapshot[] let observer: IntersectionObserver | null = null;
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(step => Object.keys(step))]);
const keyframes: Record<string, Array<string | number>> = {}; onMounted(() => {
if (!rootRef.value) return;
for (const key of keys) { observer = new IntersectionObserver(
keyframes[key] = [from[key], ...steps.map(step => step[key])]; ([entry]) => {
} if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
return keyframes; observer.observe(rootRef.value);
}; });
onBeforeUnmount(() => {
observer?.disconnect();
});
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 defaultFrom = computed<AnimationSnapshot>(() => 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<AnimationSnapshot[]>(() => [ const defaultTo = computed(() => [
{ {
filter: 'blur(5px)', filter: 'blur(5px)',
opacity: 0.5, opacity: 0.5,
@@ -93,71 +111,21 @@ const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1); const stepCount = computed(() => toSnapshots.value.length + 1);
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1)); const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
const times = computed(() => 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 getTransition = (index: number): Transition => ({
const animationKey = ref(0); duration: totalDuration.value,
const completionFired = ref(false); times: times.value,
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef'); delay: (index * props.delay) / 1000,
ease: props.easing
let observer: IntersectionObserver | null = null; });
const setupObserver = () => {
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);
};
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
};
const getTransition = (index: number) => {
return {
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
};
};
const handleAnimationComplete = (index: number) => { const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && !completionFired.value && props.onAnimationComplete) { if (index === elements.value.length - 1) {
completionFired.value = true; props.onAnimationComplete?.();
props.onAnimationComplete();
} }
}; };
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> </script>

View File

@@ -1,33 +1,24 @@
<template> <template>
<TabbedLayout> <TabbedLayout>
<template #preview> <template #preview>
<div class="demo-container h-[400px] overflow-hidden"> <div class="h-[400px] overflow-hidden demo-container">
<RefreshButton @refresh="forceRerender" /> <RefreshButton @refresh="forceRerender" />
<BlurText <BlurText
:key="rerenderKey" :key="rerenderKey"
text="Isn't this so cool?!" text="Isn't this so cool?!"
:delay="delay"
class-name="blur-text-demo"
:animate-by="animateBy" :animate-by="animateBy"
:direction="direction" :direction="direction"
:threshold="threshold" :delay="delay"
:root-margin="rootMargin" class-name="blur-text-demo"
:step-duration="stepDuration" @animation-complete="showToast"
@animation-complete="
() => {
showCallback && showToast();
}
"
/> />
</div> </div>
<Customize> <Customize>
<PreviewSwitch title="Show Completion Toast" v-model="showCallback" /> <div class="flex flex-wrap gap-4">
<div class="flex gap-4 flex-wrap">
<button <button
class="text-xs bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-8 px-3 transition-colors cursor-pointer" class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleAnimateBy" @click="toggleAnimateBy"
> >
Animate By: Animate By:
@@ -35,7 +26,7 @@
</button> </button>
<button <button
class="text-xs bg-[#0b0b0b] rounded-[10px] border border-[#333] hover:bg-[#222] text-white h-8 px-3 transition-colors cursor-pointer" class="bg-[#0b0b0b] hover:bg-[#222] px-3 border border-[#333] rounded-[10px] h-8 text-white text-xs transition-colors cursor-pointer"
@click="toggleDirection" @click="toggleDirection"
> >
Direction: Direction:
@@ -43,11 +34,15 @@
</button> </button>
</div> </div>
<PreviewSlider title="Delay (ms)" v-model="delay" :min="50" :max="500" :step="10" /> <PreviewSlider
title="Delay"
<PreviewSlider title="Step Duration (s)" v-model="stepDuration" :min="0.1" :max="1" :step="0.05" /> v-model="delay"
:min="50"
<PreviewSlider title="Threshold" v-model="threshold" :min="0.1" :max="1" :step="0.1" /> :max="500"
:step="10"
value-unit="ms"
@update:model-value="forceRerender"
/>
</Customize> </Customize>
<PropTable :data="propData" /> <PropTable :data="propData" />
@@ -66,30 +61,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import CliInstallation from '@/components/code/CliInstallation.vue';
import TabbedLayout from '../../components/common/TabbedLayout.vue'; import CodeExample from '@/components/code/CodeExample.vue';
import RefreshButton from '../../components/common/RefreshButton.vue'; import Dependencies from '@/components/code/Dependencies.vue';
import PropTable from '../../components/common/PropTable.vue'; import Customize from '@/components/common/Customize.vue';
import Dependencies from '../../components/code/Dependencies.vue'; import PreviewSlider from '@/components/common/PreviewSlider.vue';
import CliInstallation from '../../components/code/CliInstallation.vue'; import PropTable from '@/components/common/PropTable.vue';
import CodeExample from '../../components/code/CodeExample.vue'; import RefreshButton from '@/components/common/RefreshButton.vue';
import Customize from '../../components/common/Customize.vue'; import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
import PreviewSlider from '../../components/common/PreviewSlider.vue';
import BlurText from '../../content/TextAnimations/BlurText/BlurText.vue';
import { blurText } from '@/constants/code/TextAnimations/blurTextCode';
import { useToast } from 'primevue/usetoast';
import { useForceRerender } from '@/composables/useForceRerender'; import { useForceRerender } from '@/composables/useForceRerender';
import { blurText } from '@/constants/code/TextAnimations/blurTextCode';
import BlurText from '@/content/TextAnimations/BlurText/BlurText.vue';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
const { rerenderKey, forceRerender } = useForceRerender();
const toast = useToast();
const animateBy = ref<'words' | 'letters'>('words'); const animateBy = ref<'words' | 'letters'>('words');
const direction = ref<'top' | 'bottom'>('top'); const direction = ref<'top' | 'bottom'>('top');
const delay = ref(200); const delay = ref(200);
const stepDuration = ref(0.35);
const threshold = ref(0.1);
const rootMargin = ref('0px');
const showCallback = ref(true);
const toast = useToast();
const { rerenderKey, forceRerender } = useForceRerender();
const toggleAnimateBy = () => { const toggleAnimateBy = () => {
animateBy.value = animateBy.value === 'words' ? 'letters' : 'words'; animateBy.value = animateBy.value === 'words' ? 'letters' : 'words';
@@ -110,18 +101,23 @@ const showToast = () => {
}; };
const propData = [ const propData = [
{ name: 'text', type: 'string', default: '""', description: 'The text content to animate.' }, {
name: 'text',
type: 'string',
default: '""',
description: 'The text content to animate.'
},
{ {
name: 'animateBy', name: 'animateBy',
type: 'string', type: 'string',
default: '"words"', default: '"words"',
description: 'Determines whether to animate by "words" or "letters".' description: "Determines whether to animate by 'words' or 'letters'."
}, },
{ {
name: 'direction', name: 'direction',
type: 'string', type: 'string',
default: '"top"', default: '"top"',
description: 'Direction from which the words/letters appear ("top" or "bottom").' description: "Direction from which the words/letters appear ('top' or 'bottom')."
}, },
{ {
name: 'delay', name: 'delay',
@@ -141,16 +137,12 @@ const propData = [
default: '0.1', default: '0.1',
description: 'Intersection threshold for triggering the animation.' description: 'Intersection threshold for triggering the animation.'
}, },
{ name: 'rootMargin', type: 'string', default: '"0px"', description: 'Root margin for the intersection observer.' },
{ name: 'className', type: 'string', default: '""', description: 'Additional class names to style the component.' },
{ name: 'animationFrom', type: 'object', default: 'undefined', description: 'Custom initial animation properties.' },
{ {
name: 'animationTo', name: 'rootMargin',
type: 'array', type: 'string',
default: 'undefined', default: '"0px"',
description: 'Custom target animation properties array.' description: 'Root margin for the intersection observer.'
}, },
{ name: 'easing', type: 'function', default: '(t) => t', description: 'Custom easing function for the animation.' },
{ {
name: 'onAnimationComplete', name: 'onAnimationComplete',
type: 'function', type: 'function',