mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
[ REFACT ] : BlurText Text Animation
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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"> </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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user