A common UI pattern you'll see in mobile apps is the "native" header dynamically transitioning elements in and out or animating colors as you scroll up and down. Using Expo Router's Stack
component, we can create a reusable component that abstracts much of the logic while maintaining flexibility through prop customisation.
We'll be creating a component called AnimatedHeaderScreen
which you can quickly wrap around screens to add this functionality. While customisation will depend on specific needs, we'll be animating optional left/right icons and changing the background color, along with applying small details like a border.
What we'll be building
Prerequisites
This tutorial assumes you're using Expo Router in your project, as we'll be utilising components like Stack.Screen
. If you want to start with a fresh install, you can use the following command to create a new TypeScript project with Expo:
npx create-expo-app@latest
Diving into the implementation
import React, { useRef, ReactNode, useCallback } from "react";
import {
View,
Animated,
ScrollView,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type AnimatedHeaderScreenProps = {
children: ReactNode;
title?: string;
leftIcon?: {
name: keyof typeof Ionicons.glyphMap;
onPress: () => void;
};
rightIcon?: {
name: keyof typeof Ionicons.glyphMap;
onPress: () => void;
};
};
const colors = {
background: "#000000",
backgroundScrolled: "#1C1C1D",
headerBorder: "#2C2C2E",
borderColor: "#3A3A3C",
text: "#FFFFFF",
tint: "#4A90E2",
};
export default function AnimatedHeaderScreen({
title,
children,
leftIcon,
rightIcon,
}: AnimatedHeaderScreenProps) {
const scrollY = useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
const headerBackgroundColor = scrollY.interpolate({
inputRange: [0, 50],
outputRange: [colors.background, colors.backgroundScrolled],
extrapolate: "clamp",
});
const handleScroll = Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: false }
);
const headerBorderWidth = scrollY.interpolate({
inputRange: [0, 50],
outputRange: [0, StyleSheet.hairlineWidth],
extrapolate: "clamp",
});
const rightIconOpacity = rightIcon
? scrollY.interpolate({
inputRange: [30, 50],
outputRange: [0, 1],
extrapolate: "clamp",
})
: 0;
const rightIconTranslateY = rightIcon
? scrollY.interpolate({
inputRange: [30, 50],
outputRange: [10, 0],
extrapolate: "clamp",
})
: 0;
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitleAlign: "center",
headerTitle: title,
headerLeft: leftIcon
? () => (
<Animated.View
style={{
opacity: rightIconOpacity,
transform: [{ translateY: rightIconTranslateY }],
}}
>
<TouchableOpacity onPress={leftIcon.onPress}>
<Ionicons
name={leftIcon.name}
size={24}
color={colors.tint}
style={styles.leftIcon}
/>
</TouchableOpacity>
</Animated.View>
)
: undefined,
headerRight: rightIcon
? () => (
<Animated.View
style={{
opacity: rightIconOpacity,
transform: [{ translateY: rightIconTranslateY }],
}}
>
<TouchableOpacity onPress={rightIcon.onPress}>
<Ionicons
name={rightIcon.name}
size={24}
color={colors.tint}
style={styles.rightIcon}
/>
</TouchableOpacity>
</Animated.View>
)
: undefined,
headerBackground: () => (
<Animated.View
style={[
StyleSheet.absoluteFill,
styles.headerBackground,
{
backgroundColor: headerBackgroundColor,
borderBottomColor: colors.borderColor,
borderBottomWidth: headerBorderWidth,
},
]}
/>
),
}}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.scrollViewContent,
{ paddingBottom: insets.bottom },
]}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View style={styles.content}>{children}</View>
</ScrollView>
</>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollViewContent: {
flexGrow: 1,
},
content: {
flex: 1,
paddingHorizontal: 8,
paddingTop: 8,
},
headerBackground: {
borderBottomWidth: 0,
},
leftIcon: {
marginLeft: 16,
},
rightIcon: {
marginRight: 16,
},
});
How It Works
Tracking Scroll Position
We use an Animated.Value
to keep tabs on how far the user has scrolled:
const scrollY = useRef(new Animated.Value(0)).current;
This value updates as the user scrolls, which we'll use to drive our animations.
Smooth Transitions with Interpolation
We use interpolate
to map the scroll position to different style properties. For example:
const headerBackgroundColor = scrollY.interpolate({
inputRange: [0, 50],
outputRange: [colors.background, colors.backgroundScrolled],
extrapolate: "clamp",
});
This creates a smooth color change for the header background as you scroll from 0 to 50 pixels. The clamp
part just makes sure the color doesn't keep changing beyond what we've set.
Applying Animated Styles
We use these interpolated values in our components with Animated.View
and inline styles:
<Animated.View
style={[
StyleSheet.absoluteFill,
styles.headerBackground,
{
backgroundColor: headerBackgroundColor,
borderBottomColor: colors.borderColor,
borderBottomWidth: headerBorderWidth,
},
]}
/>
This lets the header update its look based on how far you've scrolled.
Animating Optional Elements
For things like icons, we only apply animations if they're actually there:
const rightIconOpacity = rightIcon
? scrollY.interpolate({
inputRange: [30, 50],
outputRange: [0, 1],
extrapolate: "clamp",
})
: 0;
This way, icons fade in smoothly, but only if you've included them as props.
Handling Scroll Events
We use Animated.event
to connect scroll events directly to our scrollY
value:
const handleScroll = Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: false }
);
⚠️ Note: Make sure you have useNativeDriver
set to false
or you'll encounter the error: "_this.props.onScroll is not a function (it is Object)". This occurs because the native driver can only handle a subset of styles that can be animated on the native side. We're animating non-compatible styles like backgroundColor
, which requires JavaScript based animations.
Usage
To use the AnimatedHeaderScreen
, simply wrap your screen content with it:
import { Alert, StyleSheet, Text, View } from "react-native";
import AnimatedHeaderScreen from "@/components/AnimatedHeaderScreen";
export default function HomeScreen() {
return (
<AnimatedHeaderScreen
title="Lorem"
rightIcon={{
name: "search",
onPress: () => Alert.alert("Handle search here..."),
}}
>
{/* // Mock cards to fill out the screen... */}
{Array.from({ length: 20 }, (_, index) => index + 1).map((item) => (
<View
style={[
styles.card,
{ backgroundColor: item % 2 === 0 ? "#4A90E2" : "#67B8E3" },
]}
key={item}
>
<Text style={styles.text}>{item}</Text>
</View>
))}
</AnimatedHeaderScreen>
);
}
const styles = StyleSheet.create({
card: {
height: 80,
elevation: 6,
marginTop: 16,
shadowRadius: 4,
borderRadius: 12,
shadowOpacity: 0.1,
marginHorizontal: 8,
alignItems: "center",
justifyContent: "center",
shadowOffset: { width: 0, height: 3 },
},
text: {
color: "#FFF",
fontSize: 16,
fontWeight: "bold",
},
});
That's it! You've now got a solid foundation for an animated header in your Expo Router app. Feel free to tweak the animations, add more interactive elements, or adjust the styling to fit your app's needs.
Top comments (1)
Nice🚀