Animated Text
A staggered text animation that animates each character individually
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-blurCopy and paste the following code into your project.
component/organisms/animated-text
import { StyleSheet, ViewStyle } from "react-native";import React, { memo } from "react";import Animated, { withTiming, withDelay, withSpring, LinearTransition, Easing, useAnimatedProps, useSharedValue, useAnimatedStyle,} from "react-native-reanimated";import { BlurView, type BlurViewProps } from "expo-blur";import type { StaggeredTextProps, AnimationConfig, CharacterAnimationParams, CharacterProps,} from "./types";import { DEFAULT_ANIMATION_CONFIG, DEFAULT_ENTER_FROM, DEFAULT_ENTER_TO, DEFAULT_EXIT_FROM, DEFAULT_EXIT_TO,} from "./conf";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const Character: React.FC<CharacterProps> = memo<CharacterProps>( ({ char, style, index, animationConfig, enterFrom, enterTo, exitFrom, exitTo, }: CharacterProps): React.ReactNode & React.JSX.Element & React.ReactElement => { const enterDelay = index * animationConfig.characterDelay; const exitDelay = index * (animationConfig.characterDelay * 0.5); const maxBlur = animationConfig.maxBlurIntensity ?? 12; const blurIntensity = useSharedValue<number>(maxBlur); const enteringAnimation = () => { "worklet"; const springConfig = animationConfig.spring; const timingConfig = { duration: animationConfig.characterEnterDuration, easing: Easing.out(Easing.ease), }; blurIntensity.value = maxBlur; blurIntensity.value = withDelay( enterDelay, withTiming(0, { duration: animationConfig.characterEnterDuration * 0.8, easing: Easing.out(Easing.ease), }), ); return { initialValues: { opacity: enterFrom.opacity, transform: [ { translateY: enterFrom.translateY }, { scale: enterFrom.scale }, { rotate: `${enterFrom.rotate}deg` }, ], }, animations: { opacity: withDelay( enterDelay, withTiming(enterTo.opacity, timingConfig), ), transform: [ { translateY: withDelay( enterDelay, withSpring(enterTo.translateY, springConfig), ), }, { scale: withDelay( enterDelay, withSpring(enterTo.scale, springConfig), ), }, { rotate: withDelay( enterDelay, withSpring(`${enterTo.rotate}deg`, springConfig), ), }, ], }, }; }; const exitingAnimation = () => { "worklet"; const timingConfig = { duration: animationConfig.characterExitDuration, easing: Easing.in(Easing.ease), }; blurIntensity.value = withDelay( exitDelay, withTiming(maxBlur, { duration: animationConfig.characterExitDuration * 0.6, easing: Easing.in(Easing.ease), }), ); return { initialValues: { opacity: exitFrom.opacity, transform: [ { translateY: exitFrom.translateY }, { scale: exitFrom.scale }, { rotate: `${exitFrom.rotate}deg` }, ], }, animations: { opacity: withDelay( exitDelay, withTiming(exitTo.opacity, timingConfig), ), transform: [ { translateY: withDelay( exitDelay, withTiming(exitTo.translateY, timingConfig), ), }, { scale: withDelay( exitDelay, withTiming(exitTo.scale, timingConfig), ), }, { rotate: withDelay( exitDelay, withTiming(`${exitTo.rotate}deg`, timingConfig), ), }, ], }, }; }; const animatedBlurProps = useAnimatedProps< Pick<BlurViewProps, "intensity"> >(() => ({ intensity: blurIntensity.value, })); const animatedBlurStyle = useAnimatedStyle<ViewStyle>(() => ({ opacity: blurIntensity.value > 0.5 ? 1 : 0, })); return ( <Animated.View entering={enteringAnimation} exiting={exitingAnimation} layout={LinearTransition.duration( animationConfig.layoutTransitionDuration, ).easing(Easing.out(Easing.ease))} style={styles.characterWrapper} > <Animated.Text style={style}>{char}</Animated.Text> <AnimatedBlurView style={[StyleSheet.absoluteFillObject, animatedBlurStyle]} animatedProps={animatedBlurProps} tint="prominent" experimentalBlurMethod={"dimezisBlurView"} /> </Animated.View> ); },);export const StaggeredText: React.FC<StaggeredTextProps> = memo<StaggeredTextProps>( ({ text, style, animationConfig, enterFrom, enterTo, exitFrom, exitTo, }: StaggeredTextProps): React.ReactNode & React.JSX.Element & React.ReactElement => { const characters = Array.from<string>(text); const mergedAnimationConfig: AnimationConfig = { ...DEFAULT_ANIMATION_CONFIG, ...animationConfig, spring: { ...DEFAULT_ANIMATION_CONFIG.spring, ...animationConfig?.spring, }, }; const mergedEnterFrom: CharacterAnimationParams = { ...DEFAULT_ENTER_FROM, ...enterFrom, }; const mergedEnterTo: CharacterAnimationParams = { ...DEFAULT_ENTER_TO, ...enterTo, }; const mergedExitFrom: CharacterAnimationParams = { ...DEFAULT_EXIT_FROM, ...exitFrom, }; const mergedExitTo: CharacterAnimationParams = { ...DEFAULT_EXIT_TO, ...exitTo, }; return ( <Animated.View style={styles.textWrapper} layout={LinearTransition.duration( mergedAnimationConfig.layoutTransitionDuration, ).easing(Easing.out(Easing.ease))} > {characters.map<React.JSX.Element | React.ReactNode>( (char, index) => ( <Character key={`${char}-${index}`} char={char} style={style} index={index} totalChars={characters.length} animationConfig={mergedAnimationConfig} enterFrom={mergedEnterFrom} enterTo={mergedEnterTo} exitFrom={mergedExitFrom} exitTo={mergedExitTo} /> ), )} </Animated.View> ); }, );export default memo<StaggeredTextProps>(StaggeredText);const styles = StyleSheet.create({ textWrapper: { flexDirection: "row", flexWrap: "wrap", }, characterWrapper: { position: "relative", overflow: "hidden", },});Usage
import { View, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import AnimatedText from "@/components/organisms/animated-text";import { useEffect, useState } from "react";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), StretchPro: require("@/assets/fonts/StretchPro.otf"), }); const TEXTS: string[] = [ "Do you like this?", "Isn't it awesome?", "Reacticx makes it easy.", "Enjoy using Reacticx!", ]; const [text, setText] = useState<string>(TEXTS[0]); useEffect(() => { let index = 0; const interval = setInterval(() => { index = (index + 1) % TEXTS.length; setText(TEXTS[index]); }, 2000); return () => clearInterval(interval); }, []); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <AnimatedText text={text} animationConfig={{ spring: { damping: 15, stiffness: 210, mass: 1, }, characterDelay: 15, maxBlurIntensity: 50, }} enterFrom={{ opacity: 0, translateY: 55, scale: 0.2, rotate: 0, }} exitFrom={{ opacity: 1, translateY: 0, scale: 1, rotate: 0, }} style={{ color: "#fff", fontSize: 40, fontFamily: fontLoaded ? "SfProRounded" : undefined, }} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, content: { // alignItems: "center", paddingHorizontal: 10, marginTop: 100, }, info: { padding: 24, backgroundColor: "rgba(0,0,0,0.5)", }, year: { fontSize: 13, color: "rgba(255,255,255,0.6)", marginBottom: 6, }, name: { fontSize: 20, color: "#fff", marginBottom: 4, }, artist: { fontSize: 15, color: "rgba(255,255,255,0.7)", },});Props
CharacterProps
React Native Reanimated
Expo Blur
