Animated Scroll Progresss

An animated scroll progress wrapper that reveals a floating action button

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-worklets

Copy and paste the following code into your project. component/micro-interactions/animated-scroll-progress.tsx

// @ts-ignoreimport React, { memo } from "react";import { View, StyleSheet, Dimensions, type ViewStyle } from "react-native";import Animated, {  Extrapolation,  interpolate,  interpolateColor,  useAnimatedScrollHandler,  useAnimatedStyle,  useDerivedValue,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import type { IScrollProgress } from "./types";import { scheduleOnRN } from "react-native-worklets";import { DEFAULT_CONTENT_CONTAINER_STYLE } from "./conf";const WIDTH = Dimensions.get("window").width;export const AnimatedScrollProgress: React.FC<IScrollProgress> &  React.FunctionComponent<IScrollProgress> = memo<IScrollProgress>(  ({    children,    renderInitialContent,    renderEndContent,    endReachedThreshold = 100,    endResetThreshold = 95,    fabWidth = WIDTH * 0.7,    fabHeight = 50,    fabBottomOffset = 30,    fabBackgroundColor = "white",    fabEndBackgroundColor = "forestgreen",    fabBorderRadius = 100,    showFabOnScroll = true,    fabAppearScrollOffset = 150,    fabEndScale = 1,    contentContainerStyle,    initialContentContainerStyle,    endContentContainerStyle,    fabStyle,    fabButtonStyle,    onScrollProgressChange,    onEndReached,    onEndReset,    ...props  }): React.ReactNode & React.JSX.Element & React.ReactElement => {    const contentHeight = useSharedValue<number>(1);    const scrollViewHeight = useSharedValue<number>(1);    const textTransition = useSharedValue<number>(0);    const scrollY = useSharedValue<number>(0);    const hasReachedEnd = useSharedValue<boolean>(false);    const scrollHandler = useAnimatedScrollHandler<Record<string, unknown>>({      onScroll: (event) => {        scrollY.value = event.contentOffset.y;        contentHeight.value = event.contentSize.height;        scrollViewHeight.value = event.layoutMeasurement.height;      },    });    const scrollProgress = useDerivedValue<number>(() => {      const maxScroll = contentHeight.value - scrollViewHeight.value;      if (maxScroll <= 0) return 0;      const progress = (scrollY.value / maxScroll) * 100;      if (progress >= endReachedThreshold) {        textTransition.value = withTiming<number>(1, { duration: 300 });        if (!hasReachedEnd.value) {          hasReachedEnd.value = true;          if (onEndReached) {            scheduleOnRN<[], void>(onEndReached);          }        }      } else if (progress < endResetThreshold) {        textTransition.value = withTiming<number>(0, { duration: 300 });        if (hasReachedEnd.value) {          hasReachedEnd.value = false;          if (onEndReset) {            scheduleOnRN(onEndReset);          }        }      }      const clampedProgress = Math.min(Math.max(progress, 0), 100);      if (onScrollProgressChange) {        scheduleOnRN<[number], void>(onScrollProgressChange, clampedProgress);      }      return clampedProgress;    });    const fabAnimatedStyle = useAnimatedStyle<      Pick<ViewStyle, "opacity" | "transform">    >(() => {      if (!showFabOnScroll) {        return {          opacity: 1,          transform: [{ translateY: 0 }],        };      }      const opacity = interpolate(        scrollY.value,        [0, fabAppearScrollOffset],        [0, 1],        Extrapolation.CLAMP,      );      const translateY = interpolate(        scrollY.value,        [0, fabAppearScrollOffset],        [100, 0],        Extrapolation.CLAMP,      );      return {        opacity: withTiming<number>(opacity, { duration: 300 }),        transform: [          { translateY: withTiming<number>(translateY, { duration: 300 }) },        ],      };    });    const initialTextStyle = useAnimatedStyle<      Pick<ViewStyle, "opacity" | "transform">    >(() => {      const opacity = interpolate(        textTransition.value,        [0, 0.5, 1],        [1, 0, 0],        Extrapolation.CLAMP,      );      const translateY = interpolate(        textTransition.value,        [0, 1],        [0, -20],        Extrapolation.CLAMP,      );      return {        opacity: withTiming<number>(opacity, { duration: 300 }),        transform: [          { translateY: withTiming<number>(translateY, { duration: 300 }) },        ],        position: "absolute" as const,      };    });    const newTextStyle = useAnimatedStyle<      Pick<ViewStyle, "opacity" | "transform">    >(() => {      const opacity = interpolate(        textTransition.value,        [0, 0.5, 1],        [0, 0, 1],        Extrapolation.CLAMP,      );      const translateY = interpolate(        textTransition.value,        [0, 0.5, 1],        [20, 20, 0],        Extrapolation.CLAMP,      );      return {        opacity: withTiming<number>(opacity, { duration: 300 }),        transform: [          { translateY: withTiming<number>(translateY, { duration: 300 }) },        ],      };    });    const rAnimatedContainerStyle = useAnimatedStyle<      Pick<ViewStyle, "backgroundColor" | "transform">    >(() => {      const scale = interpolate(        scrollProgress.value,        [endResetThreshold, endReachedThreshold],        [1, fabEndScale],        Extrapolation.CLAMP,      );      const backgroundColor = interpolateColor(        scrollProgress.value,        [endResetThreshold, endReachedThreshold],        [fabBackgroundColor, fabEndBackgroundColor],      );      if (fabEndScale === 1) {        return {          backgroundColor,        };      }      return {        backgroundColor,        transform: [          {            scale: withSpring<number>(scale, {              damping: 12,              stiffness: 90,              mass: 0.8,            }),          },        ],      };    });    return (      <>        <Animated.ScrollView          onScroll={scrollHandler}          {...props}          scrollEventThrottle={16}        >          {children}        </Animated.ScrollView>        <Animated.View          style={[            styles.fab,            {              width: fabWidth,              height: fabHeight,              bottom: fabBottomOffset,            },            fabAnimatedStyle,            fabStyle,          ]}        >          <Animated.View            style={[              styles.fabButton,              {                borderRadius: fabBorderRadius,              },              rAnimatedContainerStyle,              fabButtonStyle,            ]}          >            <View              style={[DEFAULT_CONTENT_CONTAINER_STYLE, contentContainerStyle]}            >              <Animated.View                style={[initialTextStyle, initialContentContainerStyle]}              >                {renderInitialContent()}              </Animated.View>              <Animated.View style={[newTextStyle, endContentContainerStyle]}>                {renderEndContent()}              </Animated.View>            </View>          </Animated.View>        </Animated.View>      </>    );  },);export default memo<  React.FC<IScrollProgress> & React.FunctionComponent<IScrollProgress>>(AnimatedScrollProgress);const styles = StyleSheet.create({  fab: {    position: "absolute",    alignSelf: "center",  },  fabButton: {    flex: 1,    shadowColor: "#000",    shadowOffset: {      width: 0,      height: 2,    },    shadowOpacity: 0.25,    shadowRadius: 3.84,    elevation: 5,  },});

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 { SymbolView } from "expo-symbols";import { useSharedValue } from "react-native-reanimated";import AnimatedScrollProgress from "@/components/micro-interactions/animated-scroll-progress";import { CircularProgress } from "@/components/organisms/circular-progress";const STORY = {  title: "An Afterlife Denied",  author: "James Scott",  date: "August 03, 2023",  content: `It was almost impossible to make out the lone figure, shuffling slowly across the expanse of sand, far below. Only his movement gave him away. Wrapped in pale shreds of clothing, caked in tan grit and burnt red by the oppressive sun, he was all but a part of the desert already.Anubis, crouched in the shade of a rocky outcrop, panted his jackal tongue in an effort to cool himself. The heat of the clear day was extreme, the weight of its pressure, unrelenting. He pressed as much of his bare skin against the rocks as possible, his fingers spread wide against the sand-smoothed surface, all in an attempt to absorb their stored shade.Unlike his prey, he had come prepared, and lapped greedily at the water from one of the many skins hung from his belt. It was not a day he would have chosen to enter the shifting sands. He sighed and shook his head.The man's persistent stumbling had taken him out of view over the next rise. Reluctantly, Anubis stood. He took up his long, curved spear, and set out into the sun to continue the pursuit.His endless trailing of the single soul was growing tiresome. As soon as he left the shelter of the rocks and felt the sting of the suns rays upon his bare skin yet again, he made a snap decision. It was time to speed things up.Anubis strode on long legs across the dunes, consuming the distance between him and his prey quickly. The god towered over any human, and found his height most useful.By noon, Anubis had caught up to the man. The weakling had fallen again. He approached in a way that cast his shadow long over the back of the poor soul, who trembled there on his hands and knees."But I'm not done yet…" He whispered."I would not be here, if that were true," Anubis said, extending an open hand.To his dismay, the dying man turned and swatted his long, clawed fingers away and pushed himself to his feet."No." He croaked, and began walking again in earnest.Anubis held still for a moment. His nose twitching, his lip curling in outrage. He could understand the reluctance of some humans - the young, the unexpectedly injured or the rapidly sick. But this? It was clear cut. Why fight it?"Look around!" He snarled, "What chance do you have?""A better one than if I go with you!" The man said, not wasting the energy to turn his head."Give it up, human! Your time is done. Come with me to Osiris, be weighed and end this misery!""I told you." The fool coughed over split and scabbed lips, "I'm not done yet."He ducked past the jackal-headed God, and kept walking into the endless dunes. He held his head high, somehow stronger for his defiance.`,};export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    Coolvetica: require("@/assets/fonts/CoolveticaLt-Regular.ttf"),  });  const progress = useSharedValue<number>(0);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <AnimatedScrollProgress        fabWidth={280}        fabHeight={56}        fabBottomOffset={50}        fabBackgroundColor="#151515"        fabEndBackgroundColor="#fff"        fabBorderRadius={28}        showFabOnScroll        fabAppearScrollOffset={50}        onScrollProgressChange={(_value) => {          progress.value = _value;        }}        renderInitialContent={() => (          <View style={styles.fabContent}>            <View style={styles.fabTextContent}>              <Text                style={[                  styles.fabTitle,                  fontLoaded && { fontFamily: "Coolvetica" },                ]}              >                {STORY.title}              </Text>              <Text                style={[                  styles.fabSubtitle,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                Chapter 1              </Text>            </View>            <View              style={{                position: "absolute",                left: 200,              }}            >              <CircularProgress                progress={progress}                size={36}                renderIcon={() => (                  <SymbolView                    name="arrow.forward.circle.fill"                    tintColor={"#fff"}                    size={30}                    resizeMode="scaleAspectFit"                  />                )}                strokeWidth={3}                backgroundColor="#333"              />            </View>          </View>        )}        renderEndContent={() => (          <>            <View              style={{                flexDirection: "row",                alignItems: "center",                flex: 1,              }}            >              <View>                <Text                  style={[                    { color: "#000", fontSize: 18 },                    fontLoaded && { fontFamily: "Coolvetica" },                  ]}                >                  Well done!                </Text>                <Text                  style={[                    { color: "#3d3d3d", fontSize: 12 },                    fontLoaded && { fontFamily: "SfProRounded" },                  ]}                >                  Let's move on.                </Text>              </View>              <View                style={{                  position: "absolute",                  left: 200,                }}              >                <SymbolView                  name="book.fill"                  size={36}                  style={{                    marginRight: 10,                  }}                  resizeMode="scaleAspectFit"                  tintColor="#000"                />              </View>            </View>          </>        )}      >        <View style={styles.content}>          <View style={styles.badge}>            <Text              style={[                styles.badgeText,                fontLoaded && { fontFamily: "SfProRounded" },              ]}            >              Short Story            </Text>          </View>          <Text            style={[styles.title, fontLoaded && { fontFamily: "Coolvetica" }]}          >            {STORY.title}          </Text>          <View style={styles.authorRow}>            <View style={styles.avatar}>              <SymbolView name="person.fill" size={14} tintColor="#555" />            </View>            <View>              <Text                style={[                  styles.author,                  fontLoaded && { fontFamily: "HelveticaNowDisplay" },                ]}              >                {STORY.author}              </Text>              <Text                style={[                  styles.date,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                {STORY.date}              </Text>            </View>          </View>          <View style={styles.divider} />          <Text            style={[styles.body, fontLoaded && { fontFamily: "SfProRounded" }]}          >            {STORY.content}          </Text>        </View>      </AnimatedScrollProgress>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  content: {    paddingHorizontal: 24,    paddingTop: 70,    paddingBottom: 140,  },  badge: {    alignSelf: "flex-start",    backgroundColor: "#1a1a1a",    paddingHorizontal: 12,    paddingVertical: 6,    borderRadius: 8,    marginBottom: 16,  },  badgeText: {    fontSize: 12,    color: "#555",    fontWeight: "600",  },  title: {    fontSize: 36,    fontWeight: "700",    color: "#fff",    marginBottom: 24,  },  authorRow: {    flexDirection: "row",    alignItems: "center",    gap: 12,  },  avatar: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",  },  author: {    fontSize: 15,    color: "#fff",    fontWeight: "500",  },  date: {    fontSize: 13,    color: "#555",    marginTop: 2,  },  divider: {    height: 1,    backgroundColor: "#1a1a1a",    marginVertical: 28,  },  body: {    fontSize: 17,    color: "#999",    lineHeight: 28,  },  fabContent: {    flex: 1,    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    paddingHorizontal: 0,  },  fabTextContent: {    gap: 2,  },  fabTitle: {    fontSize: 16,    fontWeight: "600",    color: "#fff",  },  fabSubtitle: {    fontSize: 12,    color: "#555",  },});

Props

React Native Reanimated
React Native Worklets