mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Added <RotatingText /> text animation
This commit is contained in:
@@ -21,7 +21,8 @@ export const CATEGORIES = [
|
||||
'Decrypted Text',
|
||||
'True Focus',
|
||||
'Scroll Float',
|
||||
'Scroll Reveal'
|
||||
'Scroll Reveal',
|
||||
'Rotating Text'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,8 @@ const textAnimations = {
|
||||
'decrypted-text': () => import("../demo/TextAnimations/DecryptedTextDemo.vue"),
|
||||
'true-focus': () => import("../demo/TextAnimations/TrueFocusDemo.vue"),
|
||||
'scroll-float': () => import("../demo/TextAnimations/ScrollFloatDemo.vue"),
|
||||
'scroll-reveal': ()=> import("../demo/TextAnimations/ScrollRevealDemo.vue")
|
||||
'scroll-reveal': ()=> import("../demo/TextAnimations/ScrollRevealDemo.vue"),
|
||||
'rotating-text': ()=> import("../demo/TextAnimations/RotatingTextDemo.vue")
|
||||
};
|
||||
|
||||
const components = {
|
||||
|
||||
26
src/constants/code/TextAnimations/rotatingTextCode.ts
Normal file
26
src/constants/code/TextAnimations/rotatingTextCode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import code from '@/content/TextAnimations/RotatingText/RotatingText.vue?raw';
|
||||
import type { CodeObject } from '../../../types/code';
|
||||
|
||||
export const rotatingText: CodeObject = {
|
||||
cli: `npx jsrepo add https://vue-bits.dev/ui/TextAnimations/RotatingText`,
|
||||
installation: `npm i motion-v`,
|
||||
usage: `<template>
|
||||
<RotatingText
|
||||
:texts="['Vue', 'Bits', 'is', 'Cool!']"
|
||||
mainClassName="px-2 sm:px-2 md:px-3 bg-green-300 text-black overflow-hidden py-0.5 sm:py-1 md:py-2 justify-center rounded-lg"
|
||||
:staggerFrom="last"
|
||||
:initial="{ y: '100%' }"
|
||||
:animate="{ y: 0 }"
|
||||
:exit="{ y: '-120%' }"
|
||||
:staggerDuration="0.025"
|
||||
splitLevelClassName="overflow-hidden pb-0.5 sm:pb-1 md:pb-1"
|
||||
:transition="{ type: "spring", damping: 30, stiffness: 400 }"
|
||||
:rotationInterval="2000"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RotatingText from "./RotatingText.vue";
|
||||
</script>`,
|
||||
code
|
||||
};
|
||||
233
src/content/TextAnimations/RotatingText/RotatingText.vue
Normal file
233
src/content/TextAnimations/RotatingText/RotatingText.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
interface RotatingTextProps {
|
||||
texts: string[];
|
||||
transition?: Transition;
|
||||
initial?: boolean | Target | VariantLabels;
|
||||
animate?: any;
|
||||
exit?: Target | VariantLabels;
|
||||
animatePresenceMode?: 'sync' | 'wait';
|
||||
animatePresenceInitial?: boolean;
|
||||
rotationInterval?: number;
|
||||
staggerDuration?: number;
|
||||
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number;
|
||||
loop?: boolean;
|
||||
auto?: boolean;
|
||||
splitBy?: string;
|
||||
onNext?: (index: number) => void;
|
||||
mainClassName?: string;
|
||||
splitLevelClassName?: string;
|
||||
elementLevelClassName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<RotatingTextProps>(), {
|
||||
transition: () => ({
|
||||
type: 'spring',
|
||||
damping: 25,
|
||||
stiffness: 300
|
||||
}),
|
||||
initial: () => ({ y: '100%', opacity: 0 }) as Target,
|
||||
animate: () => ({ y: 0, opacity: 1 }),
|
||||
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<number>(0);
|
||||
let intervalId: number | undefined = undefined;
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
return Array.from(segmenter.segment(text), segment => segment.segment);
|
||||
}
|
||||
return Array.from(text);
|
||||
};
|
||||
|
||||
const elements = computed(() => {
|
||||
const currentText: string = props.texts[currentTextIndex.value];
|
||||
|
||||
if (props.splitBy === 'characters') {
|
||||
const words = currentText.split(' ');
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1
|
||||
}));
|
||||
}
|
||||
if (props.splitBy === 'words') {
|
||||
return currentText.split(' ').map((word, i, arr) => ({
|
||||
characters: [word],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
}
|
||||
if (props.splitBy === 'lines') {
|
||||
return currentText.split('\n').map((line, i, arr) => ({
|
||||
characters: [line],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
}
|
||||
|
||||
return currentText.split(props.splitBy).map((part, i, arr) => ({
|
||||
characters: [part],
|
||||
needsSpace: i !== arr.length - 1
|
||||
}));
|
||||
});
|
||||
|
||||
const getPreviousCharsCount = (wordIndex: number): number => {
|
||||
return elements.value.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0);
|
||||
};
|
||||
|
||||
const getTotalCharsCount = (): number => {
|
||||
return elements.value.reduce((sum, word) => sum + word.characters.length, 0);
|
||||
};
|
||||
|
||||
const getStaggerDelay = (index: number, totalChars: number): number => {
|
||||
const total = totalChars;
|
||||
|
||||
if (props.staggerFrom === 'first') return index * props.staggerDuration;
|
||||
if (props.staggerFrom === 'last') return (total - 1 - index) * props.staggerDuration;
|
||||
if (props.staggerFrom === 'center') {
|
||||
const center = Math.floor(total / 2);
|
||||
return Math.abs(center - index) * props.staggerDuration;
|
||||
}
|
||||
if (props.staggerFrom === 'random') {
|
||||
const randomIndex = Math.floor(Math.random() * total);
|
||||
return Math.abs(randomIndex - index) * props.staggerDuration;
|
||||
}
|
||||
|
||||
return Math.abs((props.staggerFrom as number) - index) * props.staggerDuration;
|
||||
};
|
||||
|
||||
const handleIndexChange = (newIndex: number) => {
|
||||
currentTextIndex.value = newIndex;
|
||||
if (props.onNext) {
|
||||
props.onNext(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
const nextIndex =
|
||||
currentTextIndex.value === props.texts.length - 1
|
||||
? props.loop
|
||||
? 0
|
||||
: currentTextIndex.value
|
||||
: currentTextIndex.value + 1;
|
||||
if (nextIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
const prevIndex =
|
||||
currentTextIndex.value === 0
|
||||
? props.loop
|
||||
? props.texts.length - 1
|
||||
: currentTextIndex.value
|
||||
: currentTextIndex.value - 1;
|
||||
if (prevIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const jumpTo = (index: number) => {
|
||||
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
|
||||
if (validIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(validIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (currentTextIndex.value !== 0) {
|
||||
currentTextIndex.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
next,
|
||||
previous,
|
||||
jumpTo,
|
||||
reset
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.auto,
|
||||
newAuto => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = undefined;
|
||||
}
|
||||
|
||||
if (newAuto) {
|
||||
intervalId = setInterval(next, props.rotationInterval);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.auto) {
|
||||
intervalId = setInterval(next, props.rotationInterval);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</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')"
|
||||
layout
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
|
||||
<Motion
|
||||
tag="span"
|
||||
v-for="(char, charIndex) in wordObj.characters"
|
||||
:key="charIndex"
|
||||
:initial="initial"
|
||||
:animate="animate"
|
||||
:exit="exit"
|
||||
:transition="{
|
||||
...transition,
|
||||
delay: getStaggerDelay(getPreviousCharsCount(wordIndex) + charIndex, getTotalCharsCount())
|
||||
}"
|
||||
:class="cn('inline-block', elementLevelClassName)"
|
||||
>
|
||||
{{ char }}
|
||||
</Motion>
|
||||
<span v-if="wordObj.needsSpace" class="inline-block"> </span>
|
||||
</span>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</Motion>
|
||||
</template>
|
||||
166
src/demo/TextAnimations/RotatingTextDemo.vue
Normal file
166
src/demo/TextAnimations/RotatingTextDemo.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<TabbedLayout>
|
||||
<template #preview>
|
||||
<div class="relative min-h-[400px] max-h-[400px] overflow-hidden demo-container">
|
||||
<div
|
||||
class="flex flex-row justify-center items-center p-12 sm:p-20 md:p-24 w-full h-full overflow-hidden font-light text-[1.5rem] text-white sm:text-[1.875rem] md:text-[3rem] dark:text-muted leading-8 sm:leading-9 md:leading-none"
|
||||
>
|
||||
<LayoutGroup>
|
||||
<motion.p :is="'p'" class="flex items-center gap-[0.2em] font-black" layout>
|
||||
<motion.span
|
||||
:is="'span'"
|
||||
class="pt-0.5 sm:pt-1 md:pt-2"
|
||||
layout
|
||||
:transition="{ type: 'spring', damping: 30, stiffness: 400 }"
|
||||
>
|
||||
Creative{{ ' ' }}
|
||||
</motion.span>
|
||||
<RotatingText
|
||||
:texts="words"
|
||||
mainClassName="px-2 py-0.5 bg-[#27FF64] text-white overflow-hidden flex justify-center rounded-lg sm:py-1 md:py-2 md:px-3"
|
||||
staggerFrom="last"
|
||||
:initial="{ y: '100%' }"
|
||||
:animate="{ y: 0 }"
|
||||
:exit="{ y: '-120%' }"
|
||||
:staggerDuration="0.025"
|
||||
splitLevelClassName="overflow-hidden pb-0.5 sm:pb-1"
|
||||
:transition="{ type: 'spring', damping: 30, stiffness: 400 }"
|
||||
:rotationInterval="2000"
|
||||
/>
|
||||
</motion.p>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PropTable :data="propData" />
|
||||
<Dependencies :dependency-list="['framer-motion']" />
|
||||
</template>
|
||||
|
||||
<template #code>
|
||||
<CodeExample :code-object="rotatingText" />
|
||||
</template>
|
||||
|
||||
<template #cli>
|
||||
<CliInstallation :command="rotatingText.cli" />
|
||||
</template>
|
||||
</TabbedLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutGroup, motion } from 'motion-v';
|
||||
import { ref } from 'vue';
|
||||
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||
import CodeExample from '../../components/code/CodeExample.vue';
|
||||
import Dependencies from '../../components/code/Dependencies.vue';
|
||||
import PropTable from '../../components/common/PropTable.vue';
|
||||
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||
import { rotatingText } from '../../constants/code/TextAnimations/rotatingTextCode';
|
||||
import RotatingText from '../../content/TextAnimations/RotatingText/RotatingText.vue';
|
||||
|
||||
const words = ref(['thinking', 'coding', 'components!']);
|
||||
|
||||
const propData = [
|
||||
{
|
||||
name: 'texts',
|
||||
type: 'string[]',
|
||||
default: '[]',
|
||||
description: 'An array of text strings to be rotated.'
|
||||
},
|
||||
{
|
||||
name: 'rotationInterval',
|
||||
type: 'number',
|
||||
default: '2000',
|
||||
description: 'The interval (in milliseconds) between text rotations.'
|
||||
},
|
||||
{
|
||||
name: 'initial',
|
||||
type: 'object',
|
||||
default: '{ y: "100%", opacity: 0 }',
|
||||
description: 'Initial animation state for each element.'
|
||||
},
|
||||
{
|
||||
name: 'animate',
|
||||
type: 'object',
|
||||
default: '{ y: 0, opacity: 1 }',
|
||||
description: 'Animation state when elements enter.'
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
type: 'object',
|
||||
default: '{ y: "-120%", opacity: 0 }',
|
||||
description: 'Exit animation state for elements.'
|
||||
},
|
||||
{
|
||||
name: 'animatePresenceMode',
|
||||
type: 'string',
|
||||
default: '"wait"',
|
||||
description: "Mode for AnimatePresence; for example, 'wait' to finish exit animations before entering."
|
||||
},
|
||||
{
|
||||
name: 'animatePresenceInitial',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Determines whether the AnimatePresence component should run its initial animation.'
|
||||
},
|
||||
{
|
||||
name: 'staggerDuration',
|
||||
type: 'number',
|
||||
default: '0',
|
||||
description: "Delay between each character's animation."
|
||||
},
|
||||
{
|
||||
name: 'staggerFrom',
|
||||
type: 'string',
|
||||
default: '"first"',
|
||||
description: 'Specifies the order from which the stagger starts.'
|
||||
},
|
||||
{
|
||||
name: 'transition',
|
||||
type: 'object',
|
||||
default: '',
|
||||
description: 'Transition settings for the animations.'
|
||||
},
|
||||
{
|
||||
name: 'loop',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'Determines if the rotation should loop back to the first text after the last one.'
|
||||
},
|
||||
{
|
||||
name: 'auto',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
description: 'If true, the text rotation starts automatically.'
|
||||
},
|
||||
{
|
||||
name: 'splitBy',
|
||||
type: 'string',
|
||||
default: '"characters"',
|
||||
description: 'Determines how the text is split into animatable elements (e.g., by characters, words, or lines).'
|
||||
},
|
||||
{
|
||||
name: 'onNext',
|
||||
type: 'function',
|
||||
default: 'undefined',
|
||||
description: 'Callback function invoked when the text rotates to the next item.'
|
||||
},
|
||||
{
|
||||
name: 'mainClassName',
|
||||
type: 'string',
|
||||
default: "''",
|
||||
description: 'Additional class names for the main container element.'
|
||||
},
|
||||
{
|
||||
name: 'splitLevelClassName',
|
||||
type: 'string',
|
||||
default: "''",
|
||||
description: 'Additional class names for the container wrapping each split group (e.g., a word).'
|
||||
},
|
||||
{
|
||||
name: 'elementLevelClassName',
|
||||
type: 'string',
|
||||
default: "''",
|
||||
description: 'Additional class names for each individual animated element.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
Reference in New Issue
Block a user