Animated Header ScrollView

An iOS styled animated large-to-small header

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-linear-gradient @react-native-masked-view/masked-view expo-blur react-native-safe-area-context react-native-easing-gradient

Copy and paste the following code into your project. component/organisms/animated-header-scrollview

import { BlurView, BlurViewProps } from "expo-blur";import MaskedView from "@react-native-masked-view/masked-view";import { LinearGradient } from "expo-linear-gradient";import React, { memo } from "react";import {  Platform,  StyleSheet,  Text,  TextStyle,  View,  ViewStyle,} from "react-native";import Animated, {  Extrapolation,  interpolate,  useAnimatedProps,  useAnimatedScrollHandler,  useAnimatedStyle,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context";import { easeGradient } from "react-native-easing-gradient";import { Colors, HEADER_HEIGHT, MAX_BLUR_INTENSITY, spacing } from "./conf";import type { AnimatedHeaderProps } from "./types";const AnimatedBlurView =  Animated.createAnimatedComponent<BlurViewProps>(BlurView);export const AnimatedHeaderScrollView: React.FC<AnimatedHeaderProps> &  React.FunctionComponent<AnimatedHeaderProps> = memo<AnimatedHeaderProps>(  ({    largeTitle,    subtitle,    children,    rightComponent,    showsVerticalScrollIndicator = false,    contentContainerStyle,    headerBackgroundGradient = {      colors: ["rgba(0, 0, 0, 0.85)", "rgba(0, 0, 0, 0.8)", "transparent"],      start: { x: 0.5, y: 0 },      end: { x: 0.5, y: 1 },    },    headerBlurConfig = {      intensity: 10,      tint: Platform.OS === "ios" ? "systemThickMaterialDark" : "dark",    },    smallTitleBlurIntensity = 90,    smallTitleBlurTint = "dark",    maskGradientColors = {      start: "transparent",      middle: "rgba(0,0,0,0.99)",      end: "black",    },    largeTitleBlurIntensity = 20,    largeHeaderTitleStyle: _largeTitleStyle = { fontSize: 40 },    largeHeaderSubtitleStyle,    smallHeaderSubtitleStyle: _smallHeaderSubtitleStylez,    smallHeaderTitleStyle,  }: AnimatedHeaderProps):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const scrollY = useSharedValue<number>(0);    const insets = useSafeAreaInsets();    const onScroll = useAnimatedScrollHandler<Record<string, unknown>>({      onScroll: (event) => {        scrollY.value = event.contentOffset.y;      },    });    const animatedLargeTitleStylez = useAnimatedStyle<      Partial<Pick<TextStyle, "fontSize">>    >(() => {      const __largeTitleProps__: any = _largeTitleStyle || {};      const fontSizeValue = __largeTitleProps__["fontSize"];      const fontSize = interpolate(        -scrollY.value,        [0, 100],        [fontSizeValue, fontSizeValue * 2],        Extrapolation.CLAMP,      );      return {        fontSize,      };    });    const largeTitleStyle = useAnimatedStyle<      Partial<Pick<TextStyle, "opacity">>    >(() => {      const opacity = interpolate(        scrollY.value,        [0, 60],        [1, 0],        Extrapolation.CLAMP,      );      return {        opacity,      };    });    const smallHeaderStyle = useAnimatedStyle<      Partial<Pick<TextStyle, "opacity">>    >(() => {      const opacity = withTiming<number>(        interpolate(scrollY.value, [40, 80], [0, 1], Extrapolation.CLAMP),        {          duration: 600,        },      );      const translateY = withTiming<number>(        interpolate(scrollY.value, [40, 80], [20, 0], Extrapolation.CLAMP),        {          duration: 600,        },      );      return {        opacity,        transform: [{ translateY }],      };    });    const smallHeaderSubtitleStyle = useAnimatedStyle<      Partial<Pick<TextStyle, "opacity">>    >(() => {      const shouldShow = scrollY.value > 100;      return {        opacity: withSpring<number>(shouldShow ? 0.5 : 0, {          damping: 18,          stiffness: 120,          mass: 1.2,        }),        transform: [          {            translateY: withTiming<number>(shouldShow ? 0 : 10, {              duration: 900,            }),          },        ],      };    });    const headerBackgroundStylez = useAnimatedStyle<      Partial<Pick<ViewStyle, "opacity">>    >(() => {      const opacity = interpolate(        scrollY.value,        [0, 80],        [0, 1],        Extrapolation.CLAMP,      );      return {        opacity,      };    });    const animatedHeaderBlur = useAnimatedProps(() => {      const intensity = interpolate(        scrollY.value,        [0, 100],        [0, MAX_BLUR_INTENSITY],        Extrapolation.CLAMP,      );      return {        intensity,      } as any;    });    const largeTitleBlur = useAnimatedProps(() => {      const intensity = interpolate(        scrollY.value,        [0, 80],        [largeTitleBlurIntensity, 0],        Extrapolation.CLAMP,      );      return {        intensity,      } as any;    });    const smallTitleBlur = useAnimatedProps<      Partial<Pick<BlurViewProps, "intensity">>    >(() => {      const intensity = interpolate(        scrollY.value,        [0, 80, 100],        [0, 15, 0],        Extrapolation.CLAMP,      );      const _intensity =        scrollY.value < 30          ? withTiming<number>(0, { duration: 900 })          : intensity;      return {        intensity: _intensity,      } as any;    });    const { colors: maskColors, locations: maskLocations } = easeGradient({      colorStops: {        0: { color: maskGradientColors.start },        0.5: { color: maskGradientColors.middle },        1: { color: maskGradientColors.end },      },      extraColorStopsPerTransition: 20,    });    return (      <View style={styles.container}>        <Animated.View          style={[            styles.headerBackgroundContainer,            {              height: HEADER_HEIGHT + insets.top + 50,            },            headerBackgroundStylez,          ]}        >          {Platform.OS !== "web" ? (            <MaskedView              maskElement={                <LinearGradient                  locations={maskLocations as any}                  colors={maskColors as any}                  style={StyleSheet.absoluteFill}                  start={{ x: 0.5, y: 1 }}                  end={{ x: 0.5, y: 0 }}                />              }              style={[StyleSheet.absoluteFill]}            >              <LinearGradient                colors={headerBackgroundGradient.colors as any}                locations={headerBackgroundGradient.locations}                start={headerBackgroundGradient.start}                end={headerBackgroundGradient.end}                style={StyleSheet.absoluteFill}              />              <BlurView                intensity={headerBlurConfig.intensity}                tint={headerBlurConfig.tint as any}                style={[StyleSheet.absoluteFill]}              />            </MaskedView>          ) : (            <Animated.View              style={[StyleSheet.absoluteFill, styles.webHeaderBackground]}            />          )}        </Animated.View>        <Animated.View          style={[            styles.fixedHeader,            {              paddingTop: insets.top,              height: HEADER_HEIGHT + insets.top,            },            smallHeaderStyle,          ]}        >          <View style={styles.fixedHeaderContent}>            <View style={styles.fixedHeaderTextContainer}>              <Animated.Text                style={[styles.smallHeaderTitle, smallHeaderTitleStyle]}              >                {largeTitle}              </Animated.Text>              {subtitle && (                <Animated.Text                  style={[                    styles.smallHeaderSubtitle,                    smallHeaderSubtitleStyle,                    _smallHeaderSubtitleStylez,                  ]}                >                  {subtitle}                </Animated.Text>              )}            </View>            <MaskedView              maskElement={                <LinearGradient                  locations={maskLocations as any}                  colors={maskColors as any}                  style={StyleSheet.absoluteFill}                  start={{ x: 0.5, y: 1 }}                  end={{ x: 0.5, y: 0 }}                />              }              style={[StyleSheet.absoluteFill]}            >              <LinearGradient                colors={["transparent", "transparent"]}                style={StyleSheet.absoluteFill}              />              <AnimatedBlurView                animatedProps={smallTitleBlur}                intensity={smallTitleBlurIntensity}                tint={smallTitleBlurTint}                style={[                  styles.smallTitleBlurOverlay,                  {                    height: HEADER_HEIGHT + insets.top + 20,                  },                ]}              />            </MaskedView>            {rightComponent && (              <View style={styles.rightComponentContainer}>                {rightComponent}              </View>            )}          </View>        </Animated.View>        <Animated.ScrollView          onScroll={onScroll}          scrollEventThrottle={16}          showsVerticalScrollIndicator={showsVerticalScrollIndicator}          contentContainerStyle={[            {              paddingTop: insets.top + spacing.md,              paddingBottom: insets.bottom + spacing.xl,            },            contentContainerStyle,          ]}        >          <Animated.View style={[styles.largeTitleContainer, largeTitleStyle]}>            <View style={styles.largeTitleTextContainer}>              <Animated.Text                style={[                  styles.largeTitle,                  _largeTitleStyle,                  animatedLargeTitleStylez,                ]}              >                {largeTitle}              </Animated.Text>              {subtitle && (                <Text style={[styles.largeSubtitle, largeHeaderSubtitleStyle]}>                  {subtitle}                </Text>              )}            </View>          </Animated.View>          <View style={styles.content}>{children}</View>        </Animated.ScrollView>      </View>    );  },);export default memo<  React.FC<AnimatedHeaderProps> & React.FunctionComponent<AnimatedHeaderProps>>(AnimatedHeaderScrollView);const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: Colors.black,  },  headerBackgroundContainer: {    position: "absolute",    top: 0,    left: 0,    right: 0,    zIndex: 10,  },  webHeaderBackground: {    backgroundColor: "rgba(0, 0, 0, 0.85)",  },  smallTitleBlurOverlay: {    position: "absolute",    top: 0,    left: 0,    right: 0,    zIndex: 99,  },  fixedHeader: {    position: "absolute",    top: 0,    left: 0,    right: 0,    zIndex: 11,    justifyContent: "flex-end",  },  fixedHeaderContent: {    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    paddingHorizontal: spacing.lg,    paddingBottom: spacing.sm,  },  fixedHeaderTextContainer: {    flex: 1,    alignItems: "center",  },  smallHeaderTitle: {    fontSize: 24,    color: Colors.white,    textAlign: "center",  },  smallHeaderSubtitle: {    fontSize: 12,    color: Colors.gray[400],    textAlign: "center",  },  rightComponentContainer: {    marginLeft: spacing.md,  },  largeTitleContainer: {    paddingHorizontal: spacing.md,    marginBottom: spacing.md,  },  largeTitleTextContainer: {},  backgroundImageContainer: {    marginHorizontal: -spacing.lg,    marginBottom: spacing.md,    borderRadius: 16,    overflow: "hidden",  },  backgroundImage: {    width: "100%",    height: 200,  },  backgroundOverlay: {    ...StyleSheet.absoluteFillObject,    backgroundColor: "rgba(0, 0, 0, 0.52)",  },  largeTitleContent: {    paddingHorizontal: spacing.lg,    paddingBottom: spacing.xl,    justifyContent: "flex-end",    flex: 1,  },  largeTitle: {    fontSize: 40,    color: Colors.white,    letterSpacing: -0.5,    paddingTop: 5,  },  largeSubtitle: {    fontSize: 18,    color: Colors.gray[400],    marginTop: spacing.xs,    paddingTop: 5,  },  content: {    paddingHorizontal: spacing.md,  },  largeTitleBlurContainer: {    backgroundColor: "transparent",  },});

Usage

import { View, Text, StyleSheet, Pressable } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { Feather } from "@expo/vector-icons";import { AnimatedHeaderScrollView } from "@/components/organisms/animated-header-scrollview";import { SafeAreaProvider } from "react-native-safe-area-context";const RECENT = [  { id: "1", title: "Morning Routine", time: "6:00 AM", icon: "sun" },  { id: "2", title: "Workout", time: "7:30 AM", icon: "activity" },  { id: "3", title: "Team Standup", time: "9:00 AM", icon: "users" },];const TASKS = [  { id: "1", title: "Review designs", done: true },  { id: "2", title: "Update documentation", done: false },  { id: "3", title: "Send weekly report", done: false },  { id: "4", title: "Schedule meeting", done: true },];export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  return (    <SafeAreaProvider>      <GestureHandlerRootView style={styles.container}>        <StatusBar style="light" />        <AnimatedHeaderScrollView          largeTitle="Today"          subtitle="Wednesday, Jan 22"          largeHeaderTitleStyle={{            fontSize: 38,            fontWeight: "bold",          }}          largeHeaderSubtitleStyle={{            fontFamily: fontLoaded ? "SfProRounded" : undefined,            fontSize: 16,          }}          smallHeaderTitleStyle={{            fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined,            fontSize: 18,          }}        >          {/* Schedule Section */}          <Text            style={[              styles.sectionTitle,              fontLoaded && { fontFamily: "SfProRounded" },            ]}          >            Schedule          </Text>          <View style={styles.card}>            {RECENT.map((item, index) => (              <View                key={item.id}                style={[                  styles.scheduleItem,                  index !== RECENT.length - 1 && styles.borderBottom,                ]}              >                <View style={styles.iconBox}>                  <Feather name={item.icon as any} size={18} color="#0a84ff" />                </View>                <View style={styles.scheduleInfo}>                  <Text                    style={[                      styles.scheduleTitle,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    {item.title}                  </Text>                  <Text                    style={[                      styles.scheduleTime,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    {item.time}                  </Text>                </View>                <Feather name="chevron-right" size={18} color="#444" />              </View>            ))}          </View>          {/* Tasks Section */}          <Text            style={[              styles.sectionTitle,              fontLoaded && { fontFamily: "SfProRounded" },            ]}          >            Tasks          </Text>          <View style={styles.card}>            {TASKS.map((task, index) => (              <Pressable                key={task.id}                style={[                  styles.taskItem,                  index !== TASKS.length - 1 && styles.borderBottom,                ]}              >                <View                  style={[styles.checkbox, task.done && styles.checkboxDone]}                >                  {task.done && <Feather name="check" size={12} color="#fff" />}                </View>                <Text                  style={[                    styles.taskTitle,                    task.done && styles.taskDone,                    fontLoaded && { fontFamily: "SfProRounded" },                  ]}                >                  {task.title}                </Text>              </Pressable>            ))}          </View>          {/* Quick Actions */}          <Text            style={[              styles.sectionTitle,              fontLoaded && { fontFamily: "SfProRounded" },            ]}          >            Quick Actions          </Text>          <View style={styles.actions}>            <Pressable style={styles.actionBtn}>              <Feather name="plus" size={22} color="#30d158" />              <Text                style={[                  styles.actionText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                New Task              </Text>            </Pressable>            <Pressable style={styles.actionBtn}>              <Feather name="calendar" size={22} color="#0a84ff" />              <Text                style={[                  styles.actionText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                Schedule              </Text>            </Pressable>            <Pressable style={styles.actionBtn}>              <Feather name="bookmark" size={22} color="#ff9f0a" />              <Text                style={[                  styles.actionText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                Saved              </Text>            </Pressable>          </View>          <View style={styles.spacer} />        </AnimatedHeaderScrollView>      </GestureHandlerRootView>    </SafeAreaProvider>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",  },  sectionTitle: {    fontSize: 13,    color: "#666",    textTransform: "uppercase",    letterSpacing: 0.5,    marginBottom: 10,    marginTop: 24,    marginLeft: 4,  },  card: {    backgroundColor: "#1c1c1e",    borderRadius: 16,    overflow: "hidden",  },  borderBottom: {    borderBottomWidth: 1,    borderBottomColor: "#2c2c2e",  },  scheduleItem: {    flexDirection: "row",    alignItems: "center",    padding: 14,  },  iconBox: {    width: 36,    height: 36,    borderRadius: 10,    backgroundColor: "rgba(10,132,255,0.15)",    justifyContent: "center",    alignItems: "center",    marginRight: 12,  },  scheduleInfo: {    flex: 1,  },  scheduleTitle: {    fontSize: 16,    color: "#fff",    marginBottom: 2,  },  scheduleTime: {    fontSize: 13,    color: "#666",  },  taskItem: {    flexDirection: "row",    alignItems: "center",    padding: 14,  },  checkbox: {    width: 22,    height: 22,    borderRadius: 11,    borderWidth: 2,    borderColor: "#444",    marginRight: 12,    justifyContent: "center",    alignItems: "center",  },  checkboxDone: {    backgroundColor: "#30d158",    borderColor: "#30d158",  },  taskTitle: {    fontSize: 16,    color: "#fff",  },  taskDone: {    color: "#666",    textDecorationLine: "line-through",  },  actions: {    flexDirection: "row",    gap: 12,  },  actionBtn: {    flex: 1,    backgroundColor: "#1c1c1e",    borderRadius: 14,    paddingVertical: 20,    alignItems: "center",    gap: 8,  },  actionText: {    fontSize: 13,    color: "#fff",  },  spacer: {    height: 100,  },});

Props

GradientConfig

React Native Reanimated
React Native Safe Area Context
React Native Easing Gradient
React Native Masked View
Expo Linear Gradient
Expo Blur