Curved Marquee
An animated marquee component that scrolls text smoothly along a curved SVG path
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-svgCopy and paste the following code into your project.
component/organisms/curved-marquee
import React, { useMemo, memo } from "react";import { StyleSheet, View } from "react-native";import Animated, { useSharedValue, useAnimatedProps, useFrameCallback, type FrameInfo,} from "react-native-reanimated";import SVG, { Defs, Path, Text, TextPath, type TextPathProps,} from "react-native-svg";import { Direction, type ICurvedMarquee } from "./types";const AnimatedTextPath = Animated.createAnimatedComponent< Partial<TextPathProps> & React.ComponentProps<typeof TextPath>>(TextPath);export const CurvedMarquee: React.FC<Partial<ICurvedMarquee>> & React.FunctionComponent<Partial<ICurvedMarquee>> = memo< Partial<ICurvedMarquee>>( memo<Partial<ICurvedMarquee>>( memo<Partial<ICurvedMarquee>>( ({ text: marqueeText = "⟣ REACTICX ⟢ 🤍", speed = 500, curve = -500, direction = Direction.Left, textColor = "#ffffff", fontSize = 100, copies = 50, style, }: Partial<React.ComponentProps<typeof CurvedMarquee>> & ICurvedMarquee): | (React.ReactElement & React.JSX.Element & React.ReactNode) | null => { const offset = useSharedValue<number>(0); const text = useMemo<string>(() => { const hasTrailing = /\s|\u00A0$/.test(marqueeText); return ( (hasTrailing ? marqueeText.replace(/\s+$/, "") : marqueeText) + "\u00A0" ); }, [marqueeText]); const spacing = useMemo<number>(() => { return text.length * 2 * (fontSize * 2); }, [text, fontSize]); const pathId = useMemo<string>( () => `curved-path-${Math.random().toString(36).slice(2)}`, [], ); const pathD = useMemo<string>( () => `M-100,50 Q500,${50 + curve} 1140,50`, [curve], ); const totalText = useMemo<string>(() => { const numCopies = Math.max( copies satisfies number, Math.ceil(1800 / spacing) + 2, ); return Array(numCopies).fill(text).join(""); }, [text, spacing, copies]); useFrameCallback((frameInfo: FrameInfo) => { "worklet"; if (spacing === 0) return; const deltaTime = frameInfo.timeSincePreviousFrame ?? 16; const distance = (speed * deltaTime) / 1000; if (direction === "left") { offset.value -= distance; if (offset.value <= -spacing) { offset.value += spacing; } } else { offset.value += distance; if (offset.value >= 0) { offset.value -= spacing; } } }, spacing > 0); const animatedProps = useAnimatedProps< Required<Partial<Pick<TextPathProps, "startOffset">>> >(() => { "worklet"; return { startOffset: offset.value, }; }); if (spacing === 0) { return <View style={styles.container} />; } return ( <View style={[ styles.container, style ?? { height: 390, overflow: "hidden", }, ]} > <SVG width="100%" height="100%" viewBox="0 0 1040 190" style={styles.svg} key={curve} > <Defs> <Path id={pathId} d={pathD} fill="none" stroke="transparent" /> </Defs> <Text fill={textColor} fontSize={fontSize}> <AnimatedTextPath href={`#${pathId}`} animatedProps={animatedProps} > {totalText} </AnimatedTextPath> </Text> </SVG> </View> ); }, ), ),);const styles = StyleSheet.create({ container: { width: "100%", }, svg: { overflow: "visible", },});export default memo< React.FC<ICurvedMarquee> & React.FunctionComponent<ICurvedMarquee>>(CurvedMarquee);Usage
import { StyleSheet } from "react-native";import React from "react";import { SafeAreaView } from "react-native-safe-area-context";import { CurvedMarquee } from "@/components/organisms/curved-marquee";export default function Index() { return ( <SafeAreaView style={styles.container}> <CurvedMarquee /> </SafeAreaView> );}const styles = StyleSheet.create({ container: { flex: 1, },});Props
React Native Reanimated
React Native Svg
