Files
vue-bits/src/content/TextAnimations/RotatingText/RotatingText.vue

249 lines
6.6 KiB
Vue

<script setup lang="ts">
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
type StaggerFrom = 'first' | 'last' | 'center' | 'random' | number;
type SplitBy = 'characters' | 'words' | 'lines';
interface WordElement {
characters: string[];
needsSpace: boolean;
}
interface RotatingTextProps {
texts: string[];
transition?: Transition;
initial?: boolean | Target | VariantLabels;
animate?: Target | VariantLabels;
exit?: Target | VariantLabels;
animatePresenceMode?: 'sync' | 'wait';
animatePresenceInitial?: boolean;
rotationInterval?: number;
staggerDuration?: number;
staggerFrom?: StaggerFrom;
loop?: boolean;
auto?: boolean;
splitBy?: SplitBy;
onNext?: (index: number) => void;
mainClassName?: string;
splitLevelClassName?: string;
elementLevelClassName?: string;
}
const cn = (...classes: (string | undefined | null | boolean)[]): string => {
return classes.filter(Boolean).join(' ');
};
const props = withDefaults(defineProps<RotatingTextProps>(), {
transition: () =>
({
type: 'spring',
damping: 25,
stiffness: 300
}) as Transition,
initial: () => ({ y: '100%', opacity: 0 }) as Target,
animate: () => ({ y: 0, opacity: 1 }) as Target,
exit: () => ({ y: '-120%', opacity: 0 }) as Target,
animatePresenceMode: 'wait',
animatePresenceInitial: false,
rotationInterval: 2000,
staggerDuration: 0,
staggerFrom: 'first',
loop: true,
auto: true,
splitBy: 'characters'
});
const currentTextIndex = ref(0);
let intervalId: ReturnType<typeof setInterval> | null = null;
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const segmenter = new (Intl as any).Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].map(({ segment }) => segment);
}
return [...text];
};
const elements = computed((): WordElement[] => {
const currentText = props.texts[currentTextIndex.value];
switch (props.splitBy) {
case 'characters': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}));
}
case 'words': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: [word],
needsSpace: i !== words.length - 1
}));
}
case 'lines': {
const lines = currentText.split('\n');
return lines.map((line, i) => ({
characters: [line],
needsSpace: i !== lines.length - 1
}));
}
default: {
const parts = currentText.split(props.splitBy!);
return parts.map((part, i) => ({
characters: [part],
needsSpace: i !== parts.length - 1
}));
}
}
});
const getStaggerDelay = (index: number, totalChars: number): number => {
const { staggerDuration, staggerFrom } = props;
switch (staggerFrom) {
case 'first':
return index * staggerDuration;
case 'last':
return (totalChars - 1 - index) * staggerDuration;
case 'center': {
const center = Math.floor(totalChars / 2);
return Math.abs(center - index) * staggerDuration;
}
case 'random': {
const randomIndex = Math.floor(Math.random() * totalChars);
return Math.abs(randomIndex - index) * staggerDuration;
}
default:
return Math.abs((staggerFrom as number) - index) * staggerDuration;
}
};
const handleIndexChange = (newIndex: number): void => {
currentTextIndex.value = newIndex;
props.onNext?.(newIndex);
};
const next = (): void => {
const isAtEnd = currentTextIndex.value === props.texts.length - 1;
const nextIndex = isAtEnd ? (props.loop ? 0 : currentTextIndex.value) : currentTextIndex.value + 1;
if (nextIndex !== currentTextIndex.value) {
handleIndexChange(nextIndex);
}
};
const previous = (): void => {
const isAtStart = currentTextIndex.value === 0;
const prevIndex = isAtStart
? props.loop
? props.texts.length - 1
: currentTextIndex.value
: currentTextIndex.value - 1;
if (prevIndex !== currentTextIndex.value) {
handleIndexChange(prevIndex);
}
};
const jumpTo = (index: number): void => {
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
if (validIndex !== currentTextIndex.value) {
handleIndexChange(validIndex);
}
};
const reset = (): void => {
if (currentTextIndex.value !== 0) {
handleIndexChange(0);
}
};
const cleanupInterval = (): void => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
const startInterval = (): void => {
if (props.auto) {
intervalId = setInterval(next, props.rotationInterval);
}
};
defineExpose({
next,
previous,
jumpTo,
reset
});
watch(
() => [props.auto, props.rotationInterval] as const,
() => {
cleanupInterval();
startInterval();
},
{ immediate: true }
);
onMounted(() => {
startInterval();
});
onUnmounted(() => {
cleanupInterval();
});
</script>
<template>
<Motion
tag="span"
:class="cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)"
v-bind="$attrs"
:transition="transition"
layout
>
<span class="sr-only">
{{ texts[currentTextIndex] }}
</span>
<AnimatePresence :mode="animatePresenceMode" :initial="animatePresenceInitial">
<Motion
:key="currentTextIndex"
tag="span"
:class="cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')"
aria-hidden="true"
layout
>
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
<Motion
v-for="(char, charIndex) in wordObj.characters"
:key="charIndex"
tag="span"
:initial="initial"
:animate="animate"
:exit="exit"
:transition="{
...transition,
delay: getStaggerDelay(
elements.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0) + charIndex,
elements.reduce((sum, word) => sum + word.characters.length, 0)
)
}"
:class="cn('inline-block', elementLevelClassName)"
>
{{ char }}
</Motion>
<span v-if="wordObj.needsSpace" class="whitespace-pre"></span>
</span>
</Motion>
</AnimatePresence>
</Motion>
</template>