Animated Header ScrollView
An iOS styled animated large-to-small header
Last updated on
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-gradientCopy 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
