High-level APIs for orchestrating header motion driven by scroll — built on top of React Native Reanimated.
This library is 100% a wrapper around Reanimated. All the credit for the underlying animation engine, worklets, and primitives goes to Reanimated (and react-native-worklets). This package focuses on a specific use case: header motion + scroll orchestration (including multi-scroll/tab scenarios).
✅ This is
- A small set of components + hooks that expose a single
progressshared value and a few measurement helpers. - A scroll orchestration layer that can keep multiple scrollables in sync (e.g. tabs + pager).
❌ This is NOT
- An out-of-the-box “collapsible header” component with a baked-in look.
You build any header motion you want by animating based on progress.
You must have these installed in your app:
react-native-reanimated>= 4.0.0react-native-worklets>= 0.4.0
This package declares them as peer dependencies, so your app owns those versions. Remember to install a version of Worklets compatible with your version of Reanimated.
npm i react-native-header-motionor
yarn add react-native-header-motionFollow the official Reanimated installation instructions for your environment (Expo / bare RN).
There are three key concepts:
progress is a Reanimated SharedValue<number> that represents the normalized progress of your header animation.
0→ animation start (initial state)1→ animation end (final state)
progressThreshold is the distance needed for progress to move from 0 → 1.
You can provide it as:
- a number, or
- a function
(measuredDynamic) => threshold
If you provide a function, it uses the value measured by measureDynamic.
The library gives you two measurement callbacks that you pass to your header layout:
measureTotalHeight– attach to the outer header container to measure the total header height. Scrollables use this to addpaddingTopso content starts below the header.measureDynamic– attach to the part of the header that determines the threshold (often the animated/dynamic portion).
When you pass a header component to React Navigation / Expo Router, that header is rendered by the navigator in a different part of the React tree.
Because of that, the navigation header cannot read the HeaderMotion context, so calling useMotionProgress() inside that header would throw.
HeaderMotion.Header solves this by acting as a bridge: it runs inside the provider, reads context, and passes the values to your navigation header via a render function.
Navigation headers are special:
- Even with
headerTransparent: true, the navigator can still reserve layout space for the header container. - If you animate with translations without absolute positioning, you can end up with:
- content below becoming unclickable (an invisible parent header still sits on top), or
- content hidden under the header container.
HeaderBase and AnimatedHeaderBase are absolutely positioned to avoid those layout traps, which is especially important when you use transforms/translations.
You can use either style; pick based on your integration needs:
-
Prefer components when you want a “batteries included” wiring:
HeaderMotion.ScrollView/HeaderMotion.FlatListfor common scrollablesHeaderMotion.ScrollManagerfor custom scrollables via render-props
-
Prefer hooks when you want to build your own wrappers:
useScrollManager()(same engine asHeaderMotion.ScrollManager, but hook-based)useMotionProgress()when your header is inside the provider tree
Also:
- Use
HeaderMotion.Headerwhen your header is rendered by navigation. - Use
useMotionProgresswhen your header is rendered inside the same tree asHeaderMotion.
Examples live in the example app: example/. They demonstrate a few cases, from simple animations, to scroll orchestration and persisted header animation state between different tabs (e.g. with react-native-pager-view).
Those examples use Expo Router as the navigation library, but it should be fairly simple to do the same with plain React Navigation.
This is the core pattern used in the example app (example/src/app/simple.tsx).
import HeaderMotion, {
AnimatedHeaderBase,
type WithCollapsibleHeaderProps,
} from 'react-native-header-motion';
import { Stack } from 'expo-router';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View } from 'react-native';
export default function Screen() {
return (
<HeaderMotion>
<HeaderMotion.Header>
{(headerProps) => (
<Stack.Screen
options={{
header: () => <MyHeader {...headerProps} />,
}}
/>
)}
</HeaderMotion.Header>
<HeaderMotion.ScrollView>
{/* your scrollable content */}
</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
function MyHeader({
progress,
measureTotalHeight,
measureDynamic,
progressThreshold,
}: WithCollapsibleHeaderProps) {
const insets = useSafeAreaInsets();
const containerStyle = useAnimatedStyle(() => {
const translateY = interpolate(
progress.value,
[0, 1],
[0, -progressThreshold],
Extrapolation.CLAMP
);
return { transform: [{ translateY }] };
});
return (
<AnimatedHeaderBase
onLayout={measureTotalHeight}
style={[{ paddingTop: insets.top }, containerStyle]}
>
<Animated.View onLayout={measureDynamic}>
{/* “dynamic” part of the header */}
</Animated.View>
<View>{/* "regular" part of the header */}</View>
</AnimatedHeaderBase>
);
}In React Navigation you typically configure headers via navigation.setOptions().
Important: the header itself can’t call useMotionProgress(), so we still use HeaderMotion.Header as a bridge.
import React from 'react';
import HeaderMotion, {
AnimatedHeaderBase,
type WithCollapsibleHeaderProps,
} from 'react-native-header-motion';
import { useNavigation } from '@react-navigation/native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { View } from 'react-native';
export function MyScreen() {
return (
<HeaderMotion>
<HeaderMotion.Header>
{(headerProps) => (
<NavigationHeaderInstaller headerProps={headerProps} />
)}
</HeaderMotion.Header>
<HeaderMotion.ScrollView>{/* content */}</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
function NavigationHeaderInstaller({
headerProps,
}: {
headerProps: WithCollapsibleHeaderProps;
}) {
const navigation = useNavigation();
React.useLayoutEffect(() => {
navigation.setOptions({
header: () => <MyHeader {...headerProps} />,
});
}, [navigation, headerProps]);
return null;
}
function MyHeader({
progress,
measureTotalHeight,
measureDynamic,
progressThreshold,
}: WithCollapsibleHeaderProps) {
const insets = useSafeAreaInsets();
const containerStyle = useAnimatedStyle(() => {
const translateY = interpolate(
progress.value,
[0, 1],
[0, -progressThreshold],
Extrapolation.CLAMP
);
return { transform: [{ translateY }] };
});
return (
<AnimatedHeaderBase
onLayout={measureTotalHeight}
style={[{ paddingTop: insets.top }, containerStyle]}
>
<Animated.View onLayout={measureDynamic}>
{/* “dynamic” part of the header */}
</Animated.View>
<View>{/* "regular" part of the header */}</View>
</AnimatedHeaderBase>
);
}If you have multiple scrollables (e.g. pages in react-native-pager-view), you can keep a single header progress by:
- Creating a shared “active scroll id” using
useActiveScrollId() - Passing
activeScrollId.svto<HeaderMotion activeScrollId={...} /> - Rendering each page scrollable with a unique
scrollId
The example app shows this pattern in example/src/app/collapsible-pager.tsx using HeaderMotion.ScrollManager.
Sometimes you want to keep the native navigation header for back buttons + title, but still animate a custom header section below it.
In that case:
- set
headerTransparent: true - do not provide a custom
headercomponent - render your animated header content inside the screen under the native header
Sketch:
import HeaderMotion, {
AnimatedHeaderBase,
useMotionProgress,
} from 'react-native-header-motion';
import { Stack } from 'expo-router';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { View } from 'react-native';
export default function Screen() {
return (
<>
<Stack.Screen options={{ headerTransparent: true }} />
<HeaderMotion>
<InlineAnimatedHeader />
<HeaderMotion.ScrollView>
{/* rest of content */}
</HeaderMotion.ScrollView>
</HeaderMotion>
</>
);
}
function InlineAnimatedHeader() {
const { progress, measureTotalHeight, measureDynamic, progressThreshold } =
useMotionProgress();
const containerStyle = useAnimatedStyle(() => {
const translateY = interpolate(
progress.value,
[0, 1],
[0, -progressThreshold],
Extrapolation.CLAMP
);
return { transform: [{ translateY }] };
});
return (
<AnimatedHeaderBase onLayout={measureTotalHeight} style={containerStyle}>
<Animated.View onLayout={measureDynamic}>
{/* custom animated header content below the native header */}
</Animated.View>
<View>{/* sticky part */}</View>
</AnimatedHeaderBase>
);
}The package exports a default compound component plus hooks, types, and a couple base components.
HeaderMotion is a compound component:
HeaderMotion(provider)HeaderMotion.Header(bridge for navigation headers)HeaderMotion.ScrollView(pre-wired Animated.ScrollView)HeaderMotion.FlatList(pre-wired Animated.FlatList)HeaderMotion.ScrollManager(render-prop API for custom scrollables)
progressThreshold?: number | (measuredDynamic: number) => number- Defines how many pixels correspond to
progressgoing from0to1. - If you pass a function, it uses the value measured from
measureDynamic.
- Defines how many pixels correspond to
measureDynamic?: (e) => number- What value to read from the
onLayoutevent (defaults toheight).
- What value to read from the
measureDynamicMode?: 'mount' | 'update'- Whether
measureDynamicupdates only once or on every layout recalculation.
- Whether
activeScrollId?: SharedValue<string>- Enables multi-scroll orchestration (tabs/pager).
progressExtrapolation?: ExtrapolationType- Controls how progress behaves outside the threshold range (useful for overscroll).
Render-prop component that passes motion progress props to a header you render via navigation.
<HeaderMotion.Header>
{(headerProps) => /* pass headerProps into navigation header */}
</HeaderMotion.Header>Use this instead of useMotionProgress() when your header is rendered by React Navigation / Expo Router.
Animated ScrollView wired with:
onScrollhandlerref- automatic
paddingTopbased on measured header height
Supports scrollId?: string for multi-scroll scenarios.
Animated FlatList wired similarly to the ScrollView.
Supports scrollId?: string for multi-scroll scenarios.
Render-prop API for custom scrollables (pager pages, 3rd party lists, etc.).
<HeaderMotion.ScrollManager scrollId="A">
{(
scrollableProps,
{ originalHeaderHeight, minHeightContentContainerStyle }
) => (
<Animated.ScrollView
{...scrollableProps}
contentContainerStyle={[
minHeightContentContainerStyle,
{ paddingTop: originalHeaderHeight },
]}
/>
)}
</HeaderMotion.ScrollManager>Returns:
progress(SharedValue<number>)progressThreshold(number)measureTotalHeight(onLayoutcallback)measureDynamic(onLayoutcallback)
Only use inside the HeaderMotion provider tree.
Lower-level orchestration hook that powers the component APIs. Returns:
scrollableProps:{ onScroll, scrollEventThrottle, ref }headerMotionContext:originalHeaderHeightminHeightContentContainerStyle(helps when content is shorter than the threshold)
Helper for multi-scroll scenarios (tabs/pager). Returns:
[active, setActive]active.state(React state)active.sv(SharedValue)
Non-animated absolutely positioned header base.
Reanimated-powered, absolutely positioned header base.
WithCollapsibleHeaderProps– convenience type for headers using motion progress props.WithCollapsiblePagedHeaderProps– like above, plusactiveTabandonTabChange.
- Development workflow: see CONTRIBUTING.md
- Code of conduct: see CODE_OF_CONDUCT.md
MIT
Made with create-react-native-library