Quick Start
In this guide we'll build a collapsible header from scratch. By the end you'll have a header that slides up as the user scrolls, with a section that fades and scales away.
Make sure you have completed the Installation step and have all peer dependencies set up.
Step 1 — Wrap your screen with HeaderMotion
HeaderMotion is the root provider. Everything related to header motion — the header itself, the scrollable content, and the bridging — lives inside it.
import HeaderMotion from 'react-native-header-motion';
export default function Screen() {
return <HeaderMotion>{/* header and content will go here */}</HeaderMotion>;
}
Step 2 — Add a scrollable
Replace your regular ScrollView with HeaderMotion.ScrollView. It works just like a normal ScrollView but participates in Header Motion's scroll tracking.
export default function Screen() {
return (
<HeaderMotion>
<HeaderMotion.ScrollView>
{/* your scrollable content */}
</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
Step 3 — Bridge the header into navigation
If your header is rendered by Expo Router or React Navigation, it lives outside the HeaderMotion subtree. You need to bridge the context across that boundary.
Use HeaderMotion.Bridge to capture the context and HeaderMotion.NavigationBridge to re-provide it in the navigation header:
import HeaderMotion from 'react-native-header-motion';
import { Stack } from 'expo-router';
export default function Screen() {
return (
<HeaderMotion>
<HeaderMotion.Bridge>
{(ctx) => (
<Stack.Screen
options={{
header: () => (
<HeaderMotion.NavigationBridge value={ctx}>
<CollapsibleHeader />
</HeaderMotion.NavigationBridge>
),
}}
/>
)}
</HeaderMotion.Bridge>
<HeaderMotion.ScrollView>
{/* your scrollable content */}
</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
If your header lives inside the HeaderMotion subtree (not rendered by navigation), you can skip bridging entirely and place the header component directly as a child.
Step 4 — Build the header component
Now let's create CollapsibleHeader. It uses useMotionProgress() to get the progress shared value and progressThreshold, then drives animations with useAnimatedStyle.
The header can specify a dynamic section via HeaderMotion.Header.Dynamic. Its measured layout is used to derive the progressThreshold — the scroll offset that maps to progress = 1. Everything inside that section is up to you; the library only reads its size.
Avoid animating layout properties (width, height, padding, margin and similar) anywhere in the header. This is especially important because the layout of the header is what the library uses to calculate the progressThreshold. Whether you use measureDynamicMode="mount" or "update", animating layout properties causes unnecessary layout passes that hurt performance. You can achieve pretty much the same visual results for many scenarios by just sticking to transforms and opacity changes. See Reanimated's performance guide for more.
import HeaderMotion, { useMotionProgress } from 'react-native-header-motion';
import { StyleSheet, Text, View } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
function CollapsibleHeader() {
const { progress, progressThreshold } = useMotionProgress();
const insets = useSafeAreaInsets();
const containerStyle = useAnimatedStyle(() => {
const threshold = progressThreshold.get();
return {
transform: [
{
translateY: interpolate(
progress.get(),
[0, 1],
[0, -threshold],
Extrapolation.CLAMP
),
},
],
};
});
const titleStyle = useAnimatedStyle(() => {
const threshold = progressThreshold.get();
return {
transform: [
{
translateY: interpolate(
progress.get(),
[0, 1],
[0, threshold],
Extrapolation.CLAMP
),
},
],
};
});
const dynamicStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(
progress.get(),
[0, 0.6],
[1, 0],
Extrapolation.CLAMP
),
transform: [
{
scale: interpolate(
progress.get(),
[0, 1],
[1, 0.8],
Extrapolation.CLAMP
),
},
],
};
});
return (
<HeaderMotion.Header
style={[styles.header, { paddingTop: insets.top }, containerStyle]}
>
<Animated.View style={titleStyle}>
<Text style={styles.title}>My App</Text>
</Animated.View>
<View style={styles.dynamicWrapper}>
<HeaderMotion.Header.Dynamic style={dynamicStyle}>
<View style={styles.dynamicContent}>
<Text style={styles.subtitle}>Welcome back!</Text>
</View>
</HeaderMotion.Header.Dynamic>
</View>
</HeaderMotion.Header>
);
}
Step 5 — Don't forget to add your own styling
This library does not style anything for you — the header will look however your styles say it should. Here's the stylesheet from the example above as a starting point:
const styles = StyleSheet.create({
header: {
backgroundColor: '#304077',
},
title: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
padding: 16,
},
dynamicWrapper: {
overflow: 'hidden',
},
dynamicContent: {
padding: 16,
},
subtitle: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 16,
},
});
Putting it all together
Here's the complete screen:
Full code
import HeaderMotion, { useMotionProgress } from 'react-native-header-motion';
import { Stack } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function Screen() {
return (
<HeaderMotion>
<HeaderMotion.Bridge>
{(ctx) => (
<Stack.Screen
options={{
header: () => (
<HeaderMotion.NavigationBridge value={ctx}>
<CollapsibleHeader />
</HeaderMotion.NavigationBridge>
),
}}
/>
)}
</HeaderMotion.Bridge>
<HeaderMotion.ScrollView>
{Array.from({ length: 50 }, (_, i) => (
<View key={i} style={styles.item}>
<Text>Item {i + 1}</Text>
</View>
))}
</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
function CollapsibleHeader() {
const { progress, progressThreshold } = useMotionProgress();
const insets = useSafeAreaInsets();
const containerStyle = useAnimatedStyle(() => {
const threshold = progressThreshold.get();
return {
transform: [
{
translateY: interpolate(
progress.get(),
[0, 1],
[0, -threshold],
Extrapolation.CLAMP
),
},
],
};
});
const titleStyle = useAnimatedStyle(() => {
const threshold = progressThreshold.get();
return {
transform: [
{
translateY: interpolate(
progress.get(),
[0, 1],
[0, threshold],
Extrapolation.CLAMP
),
},
],
};
});
const dynamicStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.get(), [0, 0.6], [1, 0], Extrapolation.CLAMP),
transform: [
{
scale: interpolate(
progress.get(),
[0, 1],
[1, 0.8],
Extrapolation.CLAMP
),
},
],
}));
return (
<HeaderMotion.Header
style={[styles.header, { paddingTop: insets.top }, containerStyle]}
>
<Animated.View style={titleStyle}>
<Text style={styles.title}>My App</Text>
</Animated.View>
<View style={styles.dynamicWrapper}>
<HeaderMotion.Header.Dynamic style={dynamicStyle}>
<View style={styles.dynamicContent}>
<Text style={styles.subtitle}>Welcome back!</Text>
</View>
</HeaderMotion.Header.Dynamic>
</View>
</HeaderMotion.Header>
);
}
const styles = StyleSheet.create({
header: {
backgroundColor: '#304077',
},
title: {
color: '#fff',
fontSize: 20,
fontWeight: '700',
padding: 16,
},
dynamicWrapper: {
overflow: 'hidden',
},
dynamicContent: {
padding: 16,
},
subtitle: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 16,
},
item: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
});
Summary
HeaderMotionis the root provider that tracks everythingHeaderMotion.Headermeasures the total header height and overlays it above contentHeaderMotion.Header.Dynamicmarks the section whose layout defines theprogressThresholduseMotionProgress()gives youprogress(0→1) andprogressThresholdas shared valuesHeaderMotion.Bridge+HeaderMotion.NavigationBridgemove context into navigation-rendered headersHeaderMotion.ScrollViewis a drop-in replacement forAnimated.ScrollView- There's also
HeaderMotion.FlatList, and you can bring any scrollable you want viacreateHeaderMotionScrollable
- There's also
What's next?
Now that you have a working header, dive into the Guides to learn about header measurement, advanced animations, multiple tabs, and more.