Shimmer Wave Text

A text component with smooth shimmer waves

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @react-native-masked-view/masked-view expo-linear-gradient react-native-worklets

Copy and paste the following code into your project. component/base/shimmer-wave-text.tsx

import React, { useEffect, useState, useCallback, useMemo, memo } from "react";import {  View,  StyleSheet,  type TextStyle,  type StyleProp,  type LayoutChangeEvent,  type ViewStyle,} from "react-native";import Animated, {  withTiming,  Easing,  withRepeat,  useSharedValue,  useAnimatedStyle,  interpolate,  SharedValue,  useDerivedValue,} from "react-native-reanimated";import MaskedView from "@react-native-masked-view/masked-view";import { LinearGradient } from "expo-linear-gradient";import type {  AnimationState,  FloatConfig,  LayoutDimensions,  RoundConfig,  ShimmerConfig,  ShimmerWaveTextProps,  ISyncedAnimatedCharacter,  ISyncedTextRenderer,} from "./types";import {  DEFAULT_FLOAT_CONFIG,  DEFAULT_ROUND_CONFIG,  DEFAULT_SHIMMER_CONFIG,  DEFAULT_TEXT,  DEFAULT_TEXT_COLOR,  MASK_COLOR,  PADDING,} from "./conf";import { scheduleOnRN } from "react-native-worklets";const AnimatedCharacter = memo<ISyncedAnimatedCharacter>(  ({    char,    charIndex,    style,    textColor,    floatConfig,    shimmerProgress,    totalChars,    shimmerWidth,  }: ISyncedAnimatedCharacter): JSX.Element => {    const charPosition = totalChars > 1 ? charIndex / (totalChars - 1) : 0;    const shimmerRange = 1 + shimmerWidth * 2;    const shimmerCenterAtChar = (charPosition + shimmerWidth) / shimmerRange;    const windowSize = shimmerWidth / shimmerRange;    const floatProgress = useDerivedValue(() => {      const shimmerPos = shimmerProgress.value;      const distance = Math.abs(shimmerPos - shimmerCenterAtChar);      if (distance < windowSize) {        const normalizedDistance = distance / windowSize;        return Math.cos(normalizedDistance * Math.PI) * 0.5 + 0.5;      }      return 0;    }, [shimmerCenterAtChar, windowSize]);    const animatedStyle: StyleProp<TextStyle> = useAnimatedStyle((): {      transform: Array<{ translateY: number } | { scale: number }>;    } => {      const translateY: number = floatProgress.value * -floatConfig.distance;      const scale: number = 1 + floatProgress.value * floatConfig.scaleAmount;      return {        transform: [{ translateY }, { scale }],      };    }, [floatConfig.distance, floatConfig.scaleAmount]);    return (      <Animated.Text style={[style, { color: textColor }, animatedStyle]}>        {char}      </Animated.Text>    );  },);const TextRenderer: React.FC<ISyncedTextRenderer> = memo<ISyncedTextRenderer>(  ({    words,    textStyle,    textColor,    floatConfig,    shimmerProgress,    totalChars,    shimmerWidth,    isMask = false,  }: ISyncedTextRenderer): React.ReactNode &    JSX.Element &    React.ReactElement => {    const renderContent = useMemo((): React.ReactNode & JSX.Element[] => {      let charIndex: number = 0;      const keyPrefix: string = isMask ? "mask" : "char";      const color: string = isMask ? MASK_COLOR : textColor;      return words.flatMap<React.JSX.Element>(        (word: string, wordIndex: number): React.JSX.Element[] => {          const chars: string[] = Array.from(word);          const wordChars: React.JSX.Element[] = chars.map(            (char: string): React.JSX.Element => {              const currentCharIndex: number = charIndex;              charIndex++;              return (                <AnimatedCharacter                  key={`${keyPrefix}-${currentCharIndex}`}                  char={char}                  charIndex={currentCharIndex}                  style={textStyle}                  textColor={color}                  floatConfig={floatConfig}                  shimmerProgress={shimmerProgress}                  totalChars={totalChars}                  shimmerWidth={shimmerWidth}                />              );            },          );          if (wordIndex < words.length - 1) {            const spaceIndex = charIndex;            charIndex++;            wordChars.push(              <AnimatedCharacter                key={`${keyPrefix}-space-${wordIndex}`}                char=" "                charIndex={spaceIndex}                style={textStyle}                textColor={color}                floatConfig={floatConfig}                shimmerProgress={shimmerProgress}                totalChars={totalChars}                shimmerWidth={shimmerWidth}              />,            );          }          return wordChars;        },      );    }, [      words,      textStyle,      textColor,      floatConfig,      shimmerProgress,      totalChars,      shimmerWidth,      isMask,    ]);    return <>{renderContent}</>;  },);export const ShimmerWaveText: React.FC<ShimmerWaveTextProps> =  memo<ShimmerWaveTextProps>(    ({      text = DEFAULT_TEXT,      containerStyle,      textStyle,      textColor = DEFAULT_TEXT_COLOR,      shimmerConfig = {},      floatConfig = {},      roundConfig = {},    }: ShimmerWaveTextProps): React.ReactNode & JSX.Element => {      const mergedShimmerConfig: ShimmerConfig = useMemo<ShimmerConfig>(        (): ShimmerConfig => ({          ...DEFAULT_SHIMMER_CONFIG,          ...shimmerConfig,        }),        [shimmerConfig],      );      const mergedFloatConfig: FloatConfig = useMemo<FloatConfig>(        (): FloatConfig => ({          ...DEFAULT_FLOAT_CONFIG,          ...floatConfig,        }),        [floatConfig],      );      const mergedRoundConfig: RoundConfig = useMemo<RoundConfig>(        (): RoundConfig => ({          ...DEFAULT_ROUND_CONFIG,          ...roundConfig,        }),        [roundConfig],      );      const shimmerProgress: SharedValue<number> = useSharedValue<number>(0);      const [dimensions, setDimensions] = useState<LayoutDimensions>({        width: 0,        height: 0,      });      const [roundTrigger, setRoundTrigger] = useState<number>(0);      const animationStateRef = React.useRef<AnimationState>({        currentRound: 0,      });      const words: readonly string[] = useMemo<readonly string[]>(        (): readonly string[] => text.split(" "),        [text],      );      const totalChars: number = useMemo<number>((): number => {        return text.length;      }, [text]);      const flattenedTextStyle: TextStyle = useMemo(        (): TextStyle =>          StyleSheet.flatten([styles.text, textStyle]) as TextStyle,        [textStyle],      );      const startNextRound = useCallback((): void => {        animationStateRef.current.currentRound += 1;        setRoundTrigger((prev: number): number => prev + 1);      }, []);      useEffect((): void => {        if (dimensions.width > 0) {          const isInfinite: boolean = mergedRoundConfig.total === -1;          if (isInfinite) {            shimmerProgress.value = withRepeat(              withTiming(1, {                duration: mergedShimmerConfig.duration,                easing: Easing.linear,              }),              -1,              false,            );          } else {            const animateRound = (): void => {              shimmerProgress.value = 0;              shimmerProgress.value = withTiming(                1,                {                  duration: mergedShimmerConfig.duration,                  easing: Easing.linear,                },                (finished?: boolean): void => {                  if (finished) {                    ("worklet");                    if (                      animationStateRef.current.currentRound <                      mergedRoundConfig.total - 1                    ) {                      setTimeout((): void => {                        scheduleOnRN<[], void>(startNextRound);                      }, mergedRoundConfig.delayBetween);                    }                  }                },              );            };            animateRound();          }        }      }, [        dimensions.width,        mergedShimmerConfig.duration,        mergedRoundConfig.total,        mergedRoundConfig.delayBetween,        roundTrigger,        shimmerProgress,        startNextRound,      ]);      const animatedShimmerStyle: StyleProp<ViewStyle> =        useAnimatedStyle<ViewStyle>((): {          transform: Array<{ translateX: number }>;        } => {          const shimmerWidthValue: number =            dimensions.width * mergedShimmerConfig.width;          const translateX: number = interpolate(            shimmerProgress.value,            [0, 1],            [-shimmerWidthValue, dimensions.width + shimmerWidthValue],          );          return {            transform: [{ translateX }],          };        }, [dimensions.width, mergedShimmerConfig.width]);      const handleLayout = useCallback((event: LayoutChangeEvent): void => {        const { width, height } = event.nativeEvent.layout;        setDimensions({ width, height });      }, []);      const shimmerWidthValue: number = useMemo<number>(        (): number => dimensions.width * mergedShimmerConfig.width,        [dimensions.width, mergedShimmerConfig.width],      );      const extraSpace: number =        mergedFloatConfig.distance + mergedFloatConfig.scaleAmount * 10;      return (        <View style={[styles.container, containerStyle]}>          <View            style={[              styles.contentWrapper,              {                paddingTop: PADDING + extraSpace,                paddingBottom: PADDING,                paddingHorizontal: PADDING,              },            ]}            onLayout={handleLayout}          >            <View style={[styles.textWrapper, styles.hiddenLayer]}>              <TextRenderer                words={words}                textStyle={flattenedTextStyle}                textColor={textColor}                floatConfig={mergedFloatConfig}                shimmerProgress={shimmerProgress}                totalChars={totalChars}                shimmerWidth={mergedShimmerConfig.width}              />            </View>            {dimensions.width > 0 && (              <MaskedView                style={[                  StyleSheet.absoluteFill,                  {                    width: dimensions.width,                    height: dimensions.height + extraSpace * 2,                    left: PADDING,                    top: PADDING,                  },                ]}                maskElement={                  <View                    style={[styles.textWrapper, { paddingTop: extraSpace }]}                  >                    <TextRenderer                      words={words}                      textStyle={flattenedTextStyle}                      textColor={MASK_COLOR}                      floatConfig={mergedFloatConfig}                      shimmerProgress={shimmerProgress}                      totalChars={totalChars}                      shimmerWidth={mergedShimmerConfig.width}                      isMask                    />                  </View>                }              >                <View                  style={[                    styles.shimmerContainer,                    {                      width: dimensions.width,                      height: dimensions.height + extraSpace * 2,                      backgroundColor: textColor,                    },                  ]}                >                  <Animated.View                    style={[                      {                        width: shimmerWidthValue,                        height: "100%",                      },                      animatedShimmerStyle,                    ]}                  >                    <LinearGradient                      colors={                        mergedShimmerConfig.colors as [                          string,                          string,                          string,                          string,                          string,                          string,                        ]                      }                      start={{ x: 0, y: 0 }}                      end={{ x: 1, y: 0 }}                      style={{                        width: shimmerWidthValue,                        height: "100%",                      }}                    />                  </Animated.View>                </View>              </MaskedView>            )}          </View>        </View>      );    },  );ShimmerWaveText.displayName = "ShimmerWaveText";const styles = StyleSheet.create({  container: {    alignItems: "flex-start",    justifyContent: "center",  },  contentWrapper: {    position: "relative",    overflow: "visible",  },  textWrapper: {    flexDirection: "row",    flexWrap: "wrap",    overflow: "visible",  },  text: {    fontSize: 18,  },  hiddenLayer: {    opacity: 0,  },  shimmerContainer: {    overflow: "visible",  },});

Usage

import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { useState } from "react";import Ruler from "@/components/base/ruler";import { ShimmerWaveText } from "@/components/base/shimmer-wave-text";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const [value, setValue] = useState(18);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={{ marginTop: 100 }}>        <View style={styles.content}>          <ShimmerWaveText            text="Try Reacticx you will not regret it!"            textColor="#212121"            textStyle={{              fontFamily: fontLoaded ? "SfProRounded" : undefined,              fontSize: 30,            }}          />        </View>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",    alignItems: "center",    gap: 40,  },  content: {    alignItems: "center",    gap: 9,  },  label: {    fontSize: 15,    color: "rgba(255,255,255,0.5)",    letterSpacing: 1,    textTransform: "uppercase",  },  valueRow: {    flexDirection: "row",    alignItems: "baseline",    gap: 6,  },  value: {    fontSize: 72,    color: "#fff",    fontWeight: "600",  },  unit: {    fontSize: 24,    color: "rgba(255,255,255,0.5)",  },});

Props

AnimatedCharacterProps

React Native Reanimated
React Native Worklets
Expo Linear Gradient
React Native Masked Blur