diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx
index 5447cce..0863621 100644
--- a/example/src/app/index.tsx
+++ b/example/src/app/index.tsx
@@ -154,6 +154,21 @@ const SECTIONS: ShowcaseSection[] = [
{ title: 'Header asChild', href: '/as-child', icon: '🧩' },
],
},
+ {
+ title: 'SubHeaders',
+ data: [
+ {
+ title: 'SubHeader + FlatList',
+ href: '/subheader-flatlist',
+ icon: '🔎📋',
+ },
+ {
+ title: 'SubHeader + Pager',
+ href: '/subheader-pager',
+ icon: '🔎📑',
+ },
+ ],
+ },
{
title: 'Header Pan',
data: [
diff --git a/example/src/app/subheader-flatlist.tsx b/example/src/app/subheader-flatlist.tsx
new file mode 100644
index 0000000..5a8ae79
--- /dev/null
+++ b/example/src/app/subheader-flatlist.tsx
@@ -0,0 +1,167 @@
+import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components';
+import HeaderMotion, { useMotionProgress } from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet, TextInput, View } from 'react-native';
+import Animated, {
+ Extrapolation,
+ interpolate,
+ useAnimatedStyle,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+type ListRow = {
+ index: number;
+ label: string;
+};
+
+export default function Screen() {
+ return (
+
+
+ {(value) => (
+ (
+
+
+
+ ),
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+ `${item.index}`}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+ );
+}
+
+function CollapsibleHeader() {
+ const { progress, progressThreshold } = useMotionProgress();
+ const insets = useSafeAreaInsets();
+
+ const containerStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const translateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, -threshold],
+ Extrapolation.CLAMP
+ );
+ return { transform: [{ translateY }] };
+ });
+
+ const titleStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const translateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, threshold],
+ Extrapolation.CLAMP
+ );
+ return { transform: [{ translateY }] };
+ });
+
+ const boxSectionStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const parallaxTranslateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, threshold * 0.5],
+ Extrapolation.CLAMP
+ );
+ const opacity = interpolate(
+ progress.get(),
+ [0, 1 * 0.6],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ const scale = interpolate(
+ progress.get(),
+ [0, 1],
+ [1, 0.8],
+ Extrapolation.CLAMP
+ );
+ return {
+ opacity,
+ transform: [{ translateY: parallaxTranslateY }, { scale }],
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ headerWrapper: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamicContent: {
+ overflow: 'hidden',
+ },
+ boxContainer: {
+ flexDirection: 'row',
+ gap: 6,
+ padding: 12,
+ alignItems: 'stretch',
+ overflow: 'hidden',
+ },
+ searchBarContainer: {
+ backgroundColor: '#E3CBFC',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderBottomWidth: 1,
+ borderBottomColor: '#d6b4f6',
+ },
+ searchInput: {
+ height: 40,
+ borderRadius: 10,
+ backgroundColor: 'white',
+ borderWidth: 1,
+ borderColor: '#d9d9e3',
+ color: '#304077',
+ paddingHorizontal: 12,
+ },
+});
+
+const content: ListRow[] = Array.from({ length: 200 }, (_, k) => ({
+ index: k + 1,
+ label: 'FlatList Item',
+}));
diff --git a/example/src/app/subheader-pager.tsx b/example/src/app/subheader-pager.tsx
new file mode 100644
index 0000000..58da021
--- /dev/null
+++ b/example/src/app/subheader-pager.tsx
@@ -0,0 +1,280 @@
+import {
+ ContentCard,
+ DynamicBox,
+ TabButton,
+ TitleWithSubtitle,
+} from '@/components';
+import HeaderMotion, {
+ useActiveScrollId,
+ useMotionProgress,
+} from 'react-native-header-motion';
+import { useRef } from 'react';
+import { StyleSheet, TextInput, View } from 'react-native';
+import PagerView, {
+ type PagerViewOnPageSelectedEvent,
+} from 'react-native-pager-view';
+import Animated, {
+ Extrapolation,
+ interpolate,
+ useAnimatedStyle,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Stack } from 'expo-router/build/layouts/Stack';
+
+const indexToKey = new Map([
+ [0, 'A'],
+ [1, 'B'],
+]);
+const keyToIndex = new Map([
+ ['A', 0],
+ ['B', 1],
+]);
+
+export default function Screen() {
+ const [activeScrollId, setActiveScrollId] = useActiveScrollId('A');
+ const pagerRef = useRef(null);
+
+ const handleTabPress = (key: string) => {
+ pagerRef.current?.setPage(keyToIndex.get(key)!);
+ };
+
+ const onPageSelected = (e: PagerViewOnPageSelectedEvent) => {
+ setActiveScrollId(indexToKey.get(e.nativeEvent.position)!);
+ };
+
+ return (
+
+
+ {(value) => (
+ (
+
+
+
+ ),
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ `${item.index}`}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+
+
+
+
+
+
+ `${item.index}`}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+
+
+ );
+}
+
+function CollapsibleHeader({
+ activeTab,
+ onTabChange,
+}: {
+ activeTab: string;
+ onTabChange: (newTab: string) => void;
+}) {
+ const { progress, progressThreshold } = useMotionProgress();
+ const insets = useSafeAreaInsets();
+
+ const containerStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const translateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, -threshold],
+ Extrapolation.CLAMP
+ );
+ return { transform: [{ translateY }] };
+ });
+
+ const titleStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const translateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, threshold],
+ Extrapolation.CLAMP
+ );
+ return { transform: [{ translateY }] };
+ });
+
+ const boxSectionStyle = useAnimatedStyle(() => {
+ const threshold = progressThreshold.get();
+ const parallaxTranslateY = interpolate(
+ progress.get(),
+ [0, 1],
+ [0, threshold * 0.5],
+ Extrapolation.CLAMP
+ );
+ const opacity = interpolate(
+ progress.get(),
+ [0, 0.6],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ const scale = interpolate(
+ progress.get(),
+ [0, 1],
+ [1, 0.8],
+ Extrapolation.CLAMP
+ );
+ return {
+ opacity,
+ transform: [{ translateY: parallaxTranslateY }, { scale }],
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onTabChange('A')}
+ />
+ onTabChange('B')}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ pagerView: {
+ flex: 1,
+ },
+ dynamicContent: {
+ overflow: 'hidden',
+ },
+ headerWrapper: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ boxContainer: {
+ flexDirection: 'row',
+ gap: 6,
+ padding: 12,
+ alignItems: 'stretch',
+ overflow: 'hidden',
+ },
+ tabBar: {
+ flexDirection: 'row',
+ backgroundColor: '#FFF',
+ borderTopWidth: 1,
+ borderTopColor: '#EEE',
+ paddingBottom: 4,
+ },
+ searchBarContainerA: {
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ backgroundColor: '#bbf7d0',
+ borderBottomWidth: 1,
+ borderBottomColor: '#86efac',
+ },
+ searchBarContainerB: {
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ backgroundColor: '#bfdbfe',
+ borderBottomWidth: 1,
+ borderBottomColor: '#93c5fd',
+ },
+ searchInputA: {
+ height: 40,
+ borderRadius: 10,
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#86efac',
+ paddingHorizontal: 12,
+ color: '#14532d',
+ },
+ searchInputB: {
+ height: 40,
+ borderRadius: 10,
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#93c5fd',
+ paddingHorizontal: 12,
+ color: '#1d4ed8',
+ },
+});
+
+const content = Array.from({ length: 120 }, (_, k) => ({
+ index: k + 1,
+ label: 'FlatList Item',
+}));
diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx
index f14ffda..0cd03cc 100644
--- a/src/components/HeaderMotion.tsx
+++ b/src/components/HeaderMotion.tsx
@@ -126,6 +126,9 @@ function HeaderMotionContextProvider({
}: HeaderMotionProps) {
const dynamicMeasurement = useSharedValue(undefined);
const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0);
+ const [subHeaderHeights, setSubHeaderHeights] = useState<
+ Record
+ >({});
const progressThresholdValue = useSharedValue(
typeof progressThreshold === 'number' ? progressThreshold : Infinity
);
@@ -180,6 +183,22 @@ function HeaderMotionContextProvider({
[setOriginalHeaderHeight]
);
+ const setSubHeaderHeight = useCallback(
+ (id: string, height: number, topInset: number = 0) => {
+ setSubHeaderHeights((prev) => {
+ const current = prev[id];
+ const heightDelta = Math.abs((current?.height ?? 0) - height);
+ const insetDelta = Math.abs((current?.topInset ?? 0) - topInset);
+ if (heightDelta < 0.5 && insetDelta < 0.5) {
+ return prev;
+ }
+
+ return { ...prev, [id]: { height, topInset } };
+ });
+ },
+ []
+ );
+
const scrollValues = useSharedValue({
[DEFAULT_SCROLL_ID]: getInitialScrollValue(),
});
@@ -233,6 +252,8 @@ function HeaderMotionContextProvider({
scrollValues,
scrollToRef,
activeScrollId: activeScrollId as SharedValue | undefined,
+ subHeaderHeights,
+ setSubHeaderHeight,
}),
[
originalHeaderHeight,
@@ -243,6 +264,8 @@ function HeaderMotionContextProvider({
scrollValues,
activeScrollId,
progressThresholdValue,
+ subHeaderHeights,
+ setSubHeaderHeight,
]
);
diff --git a/src/components/SubHeader.tsx b/src/components/SubHeader.tsx
new file mode 100644
index 0000000..e0ab879
--- /dev/null
+++ b/src/components/SubHeader.tsx
@@ -0,0 +1,82 @@
+import { cloneElement, isValidElement, useEffect } from 'react';
+import type { ViewProps } from 'react-native';
+import Animated, { useAnimatedStyle } from 'react-native-reanimated';
+import { useHeaderMotionContextOrThrow } from '../context';
+import type { HeaderSubHeaderProps } from '../types';
+import { composeOnLayoutHandlers, resolveSlottableChild } from '../utils';
+import { DEFAULT_SCROLL_ID } from '../utils/defaults';
+
+const baseStyle = {
+ position: 'absolute' as const,
+ left: 0,
+ right: 0,
+ zIndex: 20,
+};
+
+export function SubHeader(props: HeaderSubHeaderProps) {
+ const ctx = useHeaderMotionContextOrThrow(
+ 'HeaderMotion.SubHeader must be used within or .'
+ );
+ const scrollId = props.scrollId ?? DEFAULT_SCROLL_ID;
+ const topInset = props.topInset ?? 0;
+ const staticHeight = props.height;
+
+ const handleLayout: ViewProps['onLayout'] = (e) => {
+ if (staticHeight !== undefined) {
+ return;
+ }
+ ctx.setSubHeaderHeight(scrollId, e.nativeEvent.layout.height, topInset);
+ };
+
+ useEffect(() => {
+ if (staticHeight === undefined) {
+ return;
+ }
+
+ ctx.setSubHeaderHeight(scrollId, staticHeight, topInset);
+ }, [ctx, scrollId, staticHeight, topInset]);
+
+ const stickyStyle = useAnimatedStyle(() => {
+ const collapsedHeaderHeight = Math.max(
+ 0,
+ ctx.originalHeaderHeight - ctx.progressThreshold.get()
+ );
+ const currentHeaderHeight =
+ ctx.originalHeaderHeight -
+ ctx.progress.get() * (ctx.originalHeaderHeight - collapsedHeaderHeight);
+
+ return {
+ top: currentHeaderHeight + topInset,
+ };
+ }, [ctx.originalHeaderHeight, topInset]);
+
+ if (props.asChild) {
+ const child = resolveSlottableChild(
+ 'HeaderMotion.SubHeader',
+ props.children
+ );
+ if (!isValidElement(child)) {
+ return null;
+ }
+ const childAsAny = child as any;
+
+ return cloneElement(childAsAny, {
+ onLayout: composeOnLayoutHandlers(
+ childAsAny.props.onLayout,
+ handleLayout
+ ),
+ style: [childAsAny.props.style, baseStyle, stickyStyle],
+ });
+ }
+
+ const { style, onLayout, ...rest } = props;
+ const userOnLayout = onLayout as ViewProps['onLayout'] | undefined;
+
+ return (
+
+ );
+}
diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx
index e86444f..e209d5d 100644
--- a/src/components/__tests__/Header.test.tsx
+++ b/src/components/__tests__/Header.test.tsx
@@ -80,6 +80,8 @@ const bridgeValue = {
activeScrollId: undefined,
scrollToRef: { current: jest.fn() },
originalHeaderHeight: 0,
+ subHeaderHeights: {},
+ setSubHeaderHeight: jest.fn(),
};
const layoutEvent = {
diff --git a/src/components/createHeaderMotionScrollable.tsx b/src/components/createHeaderMotionScrollable.tsx
index 355ce5c..146eb53 100644
--- a/src/components/createHeaderMotionScrollable.tsx
+++ b/src/components/createHeaderMotionScrollable.tsx
@@ -162,7 +162,7 @@ export function createHeaderMotionScrollable<
ref,
...scrollViewProps
} = scrollableProps;
- const { originalHeaderHeight, contentContainerMinHeight } =
+ const { originalHeaderHeight, contentContainerMinHeight, subHeaderHeight } =
headerMotionContext;
const userOnLayoutRef = useRef(rest.onLayout as UserOnLayout);
@@ -176,7 +176,11 @@ export function createHeaderMotionScrollable<
contentContainerMinHeight !== undefined
? { minHeight: contentContainerMinHeight }
: undefined,
- resolveHeaderOffsetStyle(originalHeaderHeight, headerOffsetStrategy),
+ resolveHeaderAndSubHeaderOffsetStyle(
+ originalHeaderHeight,
+ headerOffsetStrategy,
+ subHeaderHeight ?? 0
+ ),
contentContainerStyle,
],
[
@@ -185,6 +189,7 @@ export function createHeaderMotionScrollable<
ensureScrollableContentMinHeight,
headerOffsetStrategy,
originalHeaderHeight,
+ subHeaderHeight,
]
);
@@ -230,6 +235,42 @@ export function createHeaderMotionScrollable<
return TypedHeaderMotionScrollable;
}
+function resolveHeaderAndSubHeaderOffsetStyle(
+ originalHeaderHeight: number,
+ headerOffsetStrategy: HeaderMotionOffsetProps['headerOffsetStrategy'],
+ subHeaderHeight: number
+) {
+ const totalOffset = originalHeaderHeight + subHeaderHeight;
+ const base = resolveHeaderOffsetStyle(
+ originalHeaderHeight,
+ headerOffsetStrategy
+ );
+
+ if (!base) {
+ return undefined;
+ }
+
+ if ('paddingTop' in base) {
+ return { paddingTop: totalOffset };
+ }
+
+ if ('marginTop' in base) {
+ return { marginTop: totalOffset };
+ }
+
+ if ('top' in base) {
+ return {
+ top: totalOffset,
+ paddingBottom: totalOffset,
+ };
+ }
+
+ return {
+ transform: [{ translateY: totalOffset }],
+ paddingBottom: totalOffset,
+ };
+}
+
function useContentContainerProps({
children: rawChildren,
mode,
diff --git a/src/components/index.ts b/src/components/index.ts
index dc87f24..88adfac 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -6,3 +6,4 @@ export * from './HeaderMotion';
export * from './ScrollManager';
export * from './ScrollView';
export * from './createHeaderMotionScrollable';
+export * from './SubHeader';
diff --git a/src/hooks/useScrollManager.ts b/src/hooks/useScrollManager.ts
index cbc618c..06abbf0 100644
--- a/src/hooks/useScrollManager.ts
+++ b/src/hooks/useScrollManager.ts
@@ -374,7 +374,7 @@ export function useScrollManager(
scrollId?: string,
options?: UseScrollManagerOptions
): ScrollManagerConfig {
- const { originalHeaderHeight } = useScrollManagerContext();
+ const { originalHeaderHeight, subHeaderHeights } = useScrollManagerContext();
const id = scrollId ?? DEFAULT_SCROLL_ID;
const ensureScrollableContentMinHeight =
@@ -425,6 +425,9 @@ export function useScrollManager(
const headerMotionContext = {
originalHeaderHeight,
contentContainerMinHeight,
+ subHeaderHeight:
+ (subHeaderHeights[id]?.height ?? 0) +
+ (subHeaderHeights[id]?.topInset ?? 0),
};
return { scrollableProps, headerMotionContext };
diff --git a/src/index.ts b/src/index.ts
index b0075fa..524ae05 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ import {
NavigationBridge,
ScrollManager,
ScrollView,
+ SubHeader,
type CreateHeaderMotionScrollableOptions,
type HeaderProps,
type HeaderMotionBridgeProps,
@@ -17,7 +18,7 @@ import {
type HeaderMotionScrollableOwnProps,
type HeaderMotionScrollViewProps,
} from './components';
-import type { HeaderDynamicProps } from './types';
+import type { HeaderDynamicProps, HeaderSubHeaderProps } from './types';
type HeaderMotionCompound = typeof HeaderMotionContextProvider & {
/**
@@ -60,6 +61,13 @@ type HeaderMotionCompound = typeof HeaderMotionContextProvider & {
* tracking and header offsetting.
*/
FlatList: typeof FlatList;
+ /**
+ * Tab/page-local sticky sub-header that pins below the collapsing header.
+ *
+ * It measures its own height and HeaderMotion scrollables automatically add
+ * top spacing for the matching `scrollId`.
+ */
+ SubHeader: typeof SubHeader;
};
/**
@@ -100,6 +108,7 @@ const HeaderMotion: HeaderMotionCompound = Object.assign(
ScrollManager,
ScrollView,
FlatList,
+ SubHeader,
}
);
@@ -112,6 +121,7 @@ export { Bridge, Header, NavigationBridge };
export type {
CreateHeaderMotionScrollableOptions,
HeaderDynamicProps,
+ HeaderSubHeaderProps,
HeaderMotionFlatListProps,
HeaderMotionBridgeProps,
HeaderMotionNavigationBridgeProps,
diff --git a/src/types.ts b/src/types.ts
index 6d1a682..d4df368 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -97,6 +97,29 @@ export type HeaderDefaultProps = AnimatedProps & {
export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps;
+export type HeaderSubHeaderProps = (HeaderDefaultProps | HeaderAsChildProps) & {
+ /**
+ * Scrollable identifier this sub-header belongs to.
+ *
+ * In single-scroll screens, you can omit it.
+ * In tab/pager setups, set this to the same value as the page's `scrollId`.
+ */
+ scrollId?: string;
+ /**
+ * Extra top inset added below the visible/collapsed header.
+ *
+ * @default 0
+ */
+ topInset?: number;
+ /**
+ * Optional static height hint for eager layout reservation.
+ *
+ * Provide this when sub-header height is known to avoid first-render padding
+ * shifts before dynamic measurement completes.
+ */
+ height?: number;
+};
+
export interface HeaderMotionBridgeValue extends MotionProgress {
measureTotalHeight: MeasureAnimatedHeaderAndSet;
measureDynamic: MeasureAnimatedHeaderAndSet;
@@ -105,11 +128,14 @@ export interface HeaderMotionBridgeValue extends MotionProgress {
activeScrollId: SharedValue | undefined;
scrollToRef: React.RefObject;
originalHeaderHeight: number;
+ subHeaderHeights: Record;
+ setSubHeaderHeight: (id: string, height: number, topInset?: number) => void;
}
export interface ScrollManagerHeaderMotionContext {
originalHeaderHeight: number;
contentContainerMinHeight?: number;
+ subHeaderHeight?: number;
}
export interface ScrollManagerConfig {