Staggered Text

A smooth staggered text animation built using Skia

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @shopify/react-native-skia 

Copy and paste the following code into your project. component/organisms/staggered-text

import React, { memo, useEffect, useMemo, useRef } from "react";import { View, StyleSheet, Platform } from "react-native";import {  Canvas,  Text as SkiaText,  useFont,  matchFont,  Blur,  Group,  SkFont,} from "@shopify/react-native-skia";import {  useSharedValue,  withTiming,  withDelay,  useDerivedValue,  interpolate,  cancelAnimation,} from "react-native-reanimated";import type {  IAnimationConfig,  ICharacterAnimationParams,  ICharacterMetrics,  ICharacterRenderer,  IStaggeredCharacterLayer,  IStaggeredText,  ITransitionCharacter,} from "./types";import { useAutoPlay } from "./hooks";import { withBuildCharacterMetrics } from "./helper";import { merge } from "./base";import {  DEFAULT_CONFIG,  DEFAULT_ENTER_FROM,  DEFAULT_ENTER_TO,  DEFAULT_EXIT_FROM,  DEFAULT_EXIT_TO,} from "./const";const CharRenderer: React.FC<ICharacterRenderer<SkFont>> &  React.FunctionComponent<ICharacterRenderer<SkFont>> = memo<  ICharacterRenderer<SkFont>>(  ({    char,    x,    y,    font,    fontSize,    color,    from,    to,    progress,  }:    | React.ComponentProps<typeof CharRenderer>    | ICharacterRenderer<SkFont>): React.ReactElement &    React.ReactNode &    React.JSX.Element => {    const animatedY = useDerivedValue<number>(() => {      const fromPx = from.translateY * fontSize;      const toPx = to.translateY * fontSize;      return interpolate(progress.value, [0, 1], [fromPx, toPx]);    });    const opacity = useDerivedValue<number>(() => {      const mid = (from.opacity + to.opacity) / 2;      return interpolate(        progress.value,        [0, 0.5, 1],        [from.opacity, mid, to.opacity],      );    });    const blurAmount = useDerivedValue<number>(() =>      interpolate(progress.value, [0, 1], [from.blur, to.blur]),    );    const scaleVal = useDerivedValue<number>(() =>      interpolate(progress.value, [0, 1], [from.scale, to.scale]),    );    const transform = useDerivedValue(() => [      { translateX: x },      { translateY: y + animatedY.value },      { scale: scaleVal.value },      { translateX: -x },    ]);    return (      <Group transform={transform} origin={{ x, y }}>        <Group opacity={opacity}>          <Blur blur={blurAmount} />          <SkiaText x={x} y={0} text={char} font={font} color={color} />        </Group>      </Group>    );  },);const StaggeredTransitionCharacter: React.FC<ITransitionCharacter<SkFont>> &  React.FunctionComponent<ITransitionCharacter<SkFont>> = memo<  ITransitionCharacter<SkFont>>(  ({    char,    x,    y,    delay,    font,    fontSize,    color,    from,    to,    direction,    config,    triggerSnapshot,    ...props  }:    | React.ComponentProps<typeof StaggeredTransitionCharacter>    | ITransitionCharacter<SkFont>): React.ReactElement &    React.ReactNode &    React.JSX.Element => {    const progress = useSharedValue<number>(direction === "in" ? 1 : 0);    useEffect(() => {      const target = direction === "in" ? 0 : 1;      const easing =        direction === "in" ? config.enterEasing : config.exitEasing;      progress.value = withDelay<number>(        delay,        withTiming<number>(target, { duration: config.duration, easing }),      );      return () => cancelAnimation<number>(progress);    }, [triggerSnapshot, direction, delay, config]);    return (      <CharRenderer        char={char}        x={x}        y={y}        font={font}        fontSize={fontSize}        color={color}        from={from}        to={to}        progress={progress}      />    );  },);const StaggeredTextTransitionLayer: React.FC<IStaggeredCharacterLayer<SkFont>> &  React.FunctionComponent<IStaggeredCharacterLayer<SkFont>> = memo<  IStaggeredCharacterLayer<SkFont>>(  ({    texts,    activeIndex,    fontSize,    color,    font,    height,    staggerFrom,    enterFrom,    enterTo,    exitFrom,    exitTo,    config,    letterSpacing,  }:    | React.ComponentProps<typeof StaggeredTextTransitionLayer>    | IStaggeredCharacterLayer<SkFont>):    | (React.ReactElement & React.ReactNode & React.JSX.Element)    | null => {    const trigger = useSharedValue<number>(0);    const prevIndexRef = useRef<number>(activeIndex);    const mesureMetrics = useMemo<ICharacterMetrics[][]>(      () =>        texts.map<ICharacterMetrics[]>((t) =>          withBuildCharacterMetrics<SkFont>(            t,            font,            staggerFrom,            config.characterDelay,            letterSpacing,          ),        ),      [texts, font, staggerFrom, config.characterDelay, letterSpacing],    );    const maxWidth = useMemo<number>(      () =>        Math.max(          ...mesureMetrics.map((m) => m.reduce((s, c) => s + c.width, 0)),          200,        ) + 100,      [mesureMetrics],    );    const outgoingIndex = prevIndexRef.current;    const incomingIndex = activeIndex;    const isTransitioning = outgoingIndex !== incomingIndex;    useEffect(() => {      if (activeIndex !== prevIndexRef.current) {        trigger.value += 1;        prevIndexRef.current = activeIndex;      }    }, [activeIndex]);    const triggerSnapshot = trigger.value;    const baseY = height / 2 + fontSize / 3;    const incomingMetrics = mesureMetrics[incomingIndex] ?? [];    const outgoingMetrics = isTransitioning      ? (mesureMetrics[outgoingIndex] ?? [])      : [];    const incomingTextWidth = incomingMetrics.reduce((s, c) => s + c.width, 0);    const outgoingTextWidth = outgoingMetrics.reduce((s, c) => s + c.width, 0);    const incomingOffsetX = (maxWidth - incomingTextWidth) / 2;    const outgoingOffsetX = (maxWidth - outgoingTextWidth) / 2;    return (      <View style={[styles.container, { height }]}>        <Canvas style={{ width: maxWidth, height }}>          {isTransitioning &&            outgoingMetrics.map((m, i) => (              <StaggeredTransitionCharacter                key={`out-${outgoingIndex}-${i}`}                char={m.char}                x={m.x + outgoingOffsetX}                y={baseY}                delay={m.delay}                font={font}                fontSize={fontSize}                color={color}                from={exitFrom}                to={exitTo}                direction="out"                config={config}                trigger={trigger}                triggerSnapshot={triggerSnapshot}              />            ))}          {incomingMetrics.map<React.ReactNode>((m, useless_index: number) => (            <StaggeredTransitionCharacter              key={`in-${incomingIndex}-${useless_index}`}              char={m.char}              x={m.x + incomingOffsetX}              y={baseY}              delay={m.delay}              font={font}              fontSize={fontSize}              color={color}              from={enterFrom}              to={enterTo}              direction="in"              config={config}              trigger={trigger}              triggerSnapshot={triggerSnapshot}            />          ))}        </Canvas>      </View>    );  },);export const StaggeredText: React.FC<IStaggeredText> &  React.FunctionComponent<IStaggeredText> = memo<IStaggeredText>(  ({    texts,    activeIndex = 0,    fontSize = 24,    color = "#ffffff",    fontPath,    height: heightProp,    staggerFrom = "leading",    letterSpacing = 1,    enterFrom: enterFromProp,    enterTo: enterToProp,    exitFrom: exitFromProp,    exitTo: exitToProp,    animationConfig: configProp,  }: React.ComponentProps<typeof StaggeredText> | IStaggeredText):    | (React.ReactElement & React.ReactNode & React.JSX.Element)    | null => {    const config = merge<Required<IAnimationConfig>>(      configProp,      DEFAULT_CONFIG,    );    const enterFrom = merge<Required<ICharacterAnimationParams>>(      enterFromProp,      DEFAULT_ENTER_FROM,    );    const enterTo = merge<Required<ICharacterAnimationParams>>(      enterToProp,      DEFAULT_ENTER_TO,    );    const exitFrom = merge<Required<ICharacterAnimationParams>>(      exitFromProp,      DEFAULT_EXIT_FROM,    );    const exitTo = merge<Required<ICharacterAnimationParams>>(      exitToProp,      DEFAULT_EXIT_TO,    );    const height = heightProp ?? fontSize * 2;    const loadedFont = useFont(fontPath ?? null, fontSize);    const systemFont = useMemo(() => {      const fontFamily = Platform.select({        ios: "Helvetica",        android: "sans-serif",        default: "System",      }) as string;      return matchFont({ fontFamily, fontSize });    }, [fontSize]);    const font = loadedFont ?? systemFont;    if (!font) return null;    return (      <StaggeredTextTransitionLayer        texts={texts}        activeIndex={activeIndex}        fontSize={fontSize}        color={color}        font={font}        height={height}        staggerFrom={staggerFrom}        enterFrom={enterFrom}        enterTo={enterTo}        exitFrom={exitFrom}        exitTo={exitTo}        config={config}        letterSpacing={letterSpacing}      />    );  },);const styles = StyleSheet.create({  container: {    overflow: "hidden",    justifyContent: "center",    alignItems: "center",  },});export default memo<  React.FC<IStaggeredText> & React.FunctionComponent<IStaggeredText>>(StaggeredText);

Usage

import React, { useEffect, useState } from "react";import { StyleSheet, View } from "react-native";import { StaggeredText } from "@/components/organisms/staggered-text";const TEXTS: string[] = [  "Do you love Reacticx!",  "Isn't it amazing?",  "Try it out now!",];export default function App(): React.ReactElement {  const [index, setIndex] = useState<number>(0);  useEffect(() => {    const interval = setInterval(() => {      setIndex((prevIndex) => (prevIndex + 1) % TEXTS.length);    }, 2000);    return () => clearInterval(interval);  }, []);  return (    <View style={styles.container}>      <StaggeredText        texts={TEXTS}        activeIndex={index}        fontSize={35}        color="#ffffff"        letterSpacing={0.5}        staggerFrom="leading"      />    </View>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0b0b0e",    justifyContent: "center",    alignItems: "center",  },});

Props

IStaggeredCharacterLayer

React Native Reanimated
React Native Skia

On this page