diff --git a/README.md b/README.md
index b4fa2c5..98bb1a4 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ Full documentation lives here:
## What it helps with
- Scroll-driven animated headers
+- Ready-made, preset-driven collapsible headers via `Collapsible` and `CollapsibleTabs`
- Shared header state across tabs, pagers, and multiple scrollables
- Navigation-rendered headers in Expo Router or React Navigation
- Custom scrollables via `createHeaderMotionScrollable()` and `ScrollablePresets`
@@ -102,6 +103,32 @@ function AppHeader() {
In a real header, use `useMotionProgress()` to drive your Reanimated styles. See the [Quick Start](https://pawicao.github.io/react-native-header-motion/docs/quick-start) for the full walkthrough, animation examples, and styling details.
+## High-level API
+
+If you'd rather not hand-write the choreography, `Collapsible` wraps the same primitives into a preset-driven compound component — and `CollapsibleTabs` does the same for one header shared across pager pages:
+
+```tsx
+import { Collapsible } from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+
+export default function Screen() {
+ return (
+
+ header }} />}
+ >
+ {/* stays in place */}
+ {/* collapses away */}
+
+
+ {/* content */}
+
+ );
+}
+```
+
+See the [high-level API docs](https://pawicao.github.io/react-native-header-motion/docs/high-level/collapsible) for presets, tabs, and pager adapters.
+
## Version notes
- Upgrading from `v0.3.x`? Read [MIGRATION-v1.md](./MIGRATION-v1.md).
diff --git a/docs/docs/high-level/collapsible-tabs.md b/docs/docs/high-level/collapsible-tabs.md
new file mode 100644
index 0000000..2552f99
--- /dev/null
+++ b/docs/docs/high-level/collapsible-tabs.md
@@ -0,0 +1,103 @@
+---
+sidebar_position: 3
+title: CollapsibleTabs
+---
+
+# CollapsibleTabs
+
+`CollapsibleTabs` shares one collapsible header across multiple swipeable pages. It absorbs everything the low-level [multi-tab setup](../guides/multiple-tabs-pages) wires by hand: the `useActiveScrollId()` state, the per-scrollable `scrollId`s, and the pager coordination.
+
+```tsx
+import { Collapsible, CollapsibleTabs } from 'react-native-header-motion';
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {about}
+
+
+
+```
+
+Note what's absent: no `scrollId` props, no active-id state, no pager event wiring. Each `CollapsibleTabs.Tab` provides its `name` as the default `scrollId` through context, so **any** header-motion scrollable inside it — including custom ones from `createHeaderMotionScrollable()` — participates automatically. An explicit `scrollId` prop still wins if you need to override.
+
+## Parts
+
+- **`CollapsibleTabs`** — the root. Extends [`Collapsible`](./collapsible) (same `preset` and pass-through props) and owns the active-tab state.
+- **`CollapsibleTabs.Pager`** — renders the pages through a pager adapter and keeps it in sync with the active tab.
+- **`CollapsibleTabs.Tab`** — one page; provides its `name` as the default `scrollId`.
+- **`CollapsibleTabs.Bar`** — a minimal, deliberately unstyled tab bar. For a custom one, build on `useCollapsibleTabs()`.
+
+All of it works inside `Collapsible.NavigationHeader` too — the tabs context is bridged across the navigation boundary automatically, so `CollapsibleTabs.Bar` can live in a navigation-rendered header.
+
+## Props
+
+### `CollapsibleTabs`
+
+Everything from `Collapsible` except `activeScrollId` (owned internally), plus:
+
+| Prop | Type | Default | Description |
+| ------------- | --------------------------------------------- | ---------------- | ----------------------------------------------------------------------- |
+| `tabs` | `(string \| { name, label? })[]` | — | The tabs in pager order. Labels are what `CollapsibleTabs.Bar` renders. |
+| `initialTab` | `string` | first tab | Name of the initially active tab. |
+| `onTabChange` | `(name: string) => void` | — | Called when the active tab changes, from a swipe or `goTo`. |
+
+### `CollapsibleTabs.Pager`
+
+| Prop | Type | Default | Description |
+| --------- | ----------------------------- | ---------------- | ---------------------------------------- |
+| `adapter` | `CollapsibleTabsPagerAdapter` | built-in pager | The pager engine. See below. |
+| `style` | `ViewStyle` | `{ flex: 1 }` | Style for the pager container. |
+
+## Pager engines
+
+The pager is pluggable through a tiny adapter interface, so the library itself has **zero pager dependencies**.
+
+**Built-in default** — a paging horizontal `ScrollView`. No extra install, keeps all pages mounted, supports swipe and programmatic changes.
+
+**`react-native-pager-view`** — for native pager behavior, hand your app's `PagerView` to the factory. The library never imports the package itself, so it stays a dependency of *your* app:
+
+```tsx
+import PagerView from 'react-native-pager-view';
+import { createPagerViewAdapter } from 'react-native-header-motion';
+
+const pagerAdapter = createPagerViewAdapter(PagerView);
+
+
+```
+
+**Custom** — implement `CollapsibleTabsPagerAdapter`: a component receiving `{ initialIndex, onIndexChange, controllerRef, style, children }`. Render `children` (one element per tab), report user-driven page changes through `onIndexChange`, and assign `{ setIndex }` to `controllerRef` for programmatic changes.
+
+## Custom tab bars
+
+`useCollapsibleTabs()` exposes the tab state anywhere in the tree:
+
+```tsx
+import { useCollapsibleTabs } from 'react-native-header-motion';
+
+function MyTabBar() {
+ const { tabs, activeTab, goTo } = useCollapsibleTabs();
+
+ return (
+
+ {tabs.map((tab) => (
+ goTo(tab.name)}
+ />
+ ))}
+
+ );
+}
+```
diff --git a/docs/docs/high-level/collapsible.md b/docs/docs/high-level/collapsible.md
new file mode 100644
index 0000000..952a396
--- /dev/null
+++ b/docs/docs/high-level/collapsible.md
@@ -0,0 +1,114 @@
+---
+sidebar_position: 1
+title: Collapsible
+---
+
+# Collapsible
+
+`Collapsible` is the high-level, preset-driven way to build a collapsible header. It wraps the `HeaderMotion` primitives — the provider, the measured header, the animated styles — into a compound component where you compose the header from explicit parts and pick a [preset](./presets) for the animation.
+
+Everything `Collapsible` does is built on the public low-level API, and every low-level prop passes through. You can start high-level and drop down at any point.
+
+## Anatomy
+
+```tsx
+import { Collapsible } from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+
+export default function Screen() {
+ return (
+
+ header }} />}
+ >
+
+
+
+
+
+
+
+
+
+ {content}
+
+ );
+}
+```
+
+Each part names the *role* a piece of UI plays while the header collapses:
+
+- **`Collapsible`** — the root provider. Renders a `HeaderMotion` provider, resolves the `preset`, and shares both with the parts. No visual output.
+- **`Collapsible.Header`** — the header frame. Measures the total header height (like `HeaderMotion.Header`) and slides up by the collapse distance as the user scrolls.
+- **`Collapsible.Pinned`** — content that stays visually in place (a title row, actions). It counter-translates against the frame's slide.
+- **`Collapsible.Dynamic`** — the collapsing section. Its measured height defines the collapse distance (like `HeaderMotion.Header.Dynamic`), and the active preset animates its content. Anything after it (like the `SearchBar` above) simply rides up and ends docked under the pinned content.
+- **`Collapsible.NavigationHeader`** — a `Collapsible.Header` rendered by a navigation library. It fuses the `Bridge` / `NavigationBridge` wiring into one component: compose the header as children, and use `render` to place the prepared element into your navigator.
+- **`Collapsible.ScrollView` / `Collapsible.FlatList`** — the same pre-wired scrollables as `HeaderMotion.ScrollView` / `HeaderMotion.FlatList`, re-exported for a self-contained import surface. Custom scrollables from `createHeaderMotionScrollable()` work here too.
+
+## Overlay headers without navigation
+
+If your header is not rendered by a navigation library, use `Collapsible.Header` directly — it overlays the content by default, exactly like the low-level `HeaderMotion.Header`:
+
+```tsx
+
+
+
+
+
+
+
+
+
+ {content}
+
+```
+
+## Props
+
+### `Collapsible`
+
+Accepts every `HeaderMotion` prop (`progressThreshold`, `measureDynamic`, `measureDynamicMode`, `activeScrollId`, `progressExtrapolation`), plus:
+
+| Prop | Type | Default | Description |
+| --------------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------- |
+| `preset` | `CollapsiblePresetInput` | `'collapse'` | How the collapsing content animates. See [Presets](./presets). |
+| `onStateChange` | `(state: 'expanded' \| 'collapsed') => void` | — | Called when the header settles into a terminal state. |
+
+### `Collapsible.Header` / `Collapsible.NavigationHeader`
+
+Accept all `HeaderMotion.Header` props except `asChild` (`overlay`, `pannable`, `panDecayConfig`, `withGestureHandlerRootView`, any `Animated.View` prop). `Collapsible.NavigationHeader` additionally requires:
+
+| Prop | Type | Description |
+| -------- | ------------------------------------- | -------------------------------------------------------------- |
+| `render` | `(header: ReactElement) => ReactNode` | Places the prepared, context-bridged header into your navigator. |
+
+### `Collapsible.Dynamic`
+
+Accepts all `HeaderMotion.Header.Dynamic` props except `asChild`, plus:
+
+| Prop | Type | Description |
+| -------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
+| `contentStyle` | `Animated.View` `style` | Style for the inner content view the preset effects animate. Layout styles for the measured wrapper go on `style`. |
+
+The wrapper clips its content by default (`overflow: 'hidden'`); override it through `style` if needed.
+
+### `Collapsible.Pinned`
+
+Accepts all `Animated.View` props.
+
+## Imperative control
+
+`useCollapsibleHeader()` exposes the motion state plus imperative controls, anywhere inside the tree (it also works in plain `HeaderMotion` trees):
+
+```tsx
+import { useCollapsibleHeader } from 'react-native-header-motion';
+
+const { progress, progressThreshold, collapse, expand } = useCollapsibleHeader();
+
+collapse(); // scrolls the active scrollable until the header is collapsed
+expand({ animated: false });
+```
+
+## Pull to refresh
+
+The scrollables accept `refreshControl` (and `refreshing` / `onRefresh`) exactly like their low-level counterparts — see [Pull to refresh](../guides/pull-to-refresh). Custom refresh indicators compose naturally into the header as regular children of `Collapsible.Header`.
diff --git a/docs/docs/high-level/presets.md b/docs/docs/high-level/presets.md
new file mode 100644
index 0000000..fccdb46
--- /dev/null
+++ b/docs/docs/high-level/presets.md
@@ -0,0 +1,73 @@
+---
+sidebar_position: 2
+title: Presets
+---
+
+# Presets
+
+A preset describes how the collapsing content of a [`Collapsible`](./collapsible) header animates. The structural choreography — the frame sliding up, pinned content counter-translating — is intrinsic to the parts; presets only decorate the collapsing section, which is what makes them freely composable.
+
+## Built-in presets
+
+Pass a name for the defaults, or call the factory from `CollapsiblePresets` to configure:
+
+```tsx
+import { Collapsible, CollapsiblePresets } from 'react-native-header-motion';
+
+ // string shorthand
+ // configured
+```
+
+| Preset | Effect | Options |
+| ---------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------- |
+| `collapse` | Content slides up and is clipped under the pinned sections (the classic iOS large-title feel). | — |
+| `fade` | Content fades out in place while the header collapses. | `from` (default `0`), `to` (default `0.8`) |
+| `parallax` | Content lags behind the collapse and fades out. | `factor` (default `0.5`), `fade` (default `true`) |
+| `scale` | Content shrinks and fades out. | `to` (default `0.9`), `fade` (default `true`) |
+| `none` | No content effect; the header still slides up. | — |
+
+## Combining presets
+
+Pass an array to combine effects. `transform` arrays concatenate in order, numeric `opacity` values multiply, and any other property is taken from the last preset that defines it:
+
+```tsx
+
+```
+
+## Custom presets
+
+A preset is a worklet from the current motion state to per-part styles. Author one with `createCollapsiblePreset()` — the function runs on the UI thread, so it **must carry the `'worklet'` directive**:
+
+```tsx
+import { createCollapsiblePreset } from 'react-native-header-motion';
+
+const lift = createCollapsiblePreset(({ progress, progressThreshold }) => {
+ 'worklet';
+ return {
+ header: { borderBottomWidth: progress },
+ dynamicContent: {
+ opacity: 1 - progress,
+ transform: [{ translateY: -progress * progressThreshold * 0.25 }],
+ },
+ };
+});
+
+
+```
+
+The returned object may style four parts:
+
+| Key | Applied to | Merged on top of |
+| ---------------- | --------------------------------------------- | ------------------------------------ |
+| `header` | `Collapsible.Header` frame | The intrinsic slide-up transform |
+| `pinned` | Every `Collapsible.Pinned` | The intrinsic counter-translate |
+| `dynamic` | The measured `Collapsible.Dynamic` wrapper | Its default `overflow: 'hidden'` |
+| `dynamicContent` | The content view inside `Collapsible.Dynamic` | — |
+
+Use transforms only on `dynamic` — its layout defines the collapse distance, and transforms don't affect layout measurement.
+
+Because presets are plain values, they are shareable: hoist them to module scope (or memoize) so the animated styles aren't rebuilt on every render, and publish the ones you're proud of.
+
+:::note Designed to grow
+The preset context (`{ progress, progressThreshold }`) is intentionally open-ended. When the headless refresh control lands, refresh state will join it, letting presets react to pull-to-refresh without any breaking change. Read the fields you need and ignore the rest.
+:::
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 48f1f42..2cc7a03 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -17,6 +17,16 @@ const sidebars: SidebarsConfig = {
id: 'quick-start',
label: 'Quick Start',
},
+ {
+ type: 'category',
+ label: 'High-level API',
+ collapsed: false,
+ items: [
+ 'high-level/collapsible',
+ 'high-level/presets',
+ 'high-level/collapsible-tabs',
+ ],
+ },
{
type: 'category',
label: 'Guides',
diff --git a/example/src/app/collapsible-overlay.tsx b/example/src/app/collapsible-overlay.tsx
new file mode 100644
index 0000000..ade30ff
--- /dev/null
+++ b/example/src/app/collapsible-overlay.tsx
@@ -0,0 +1,56 @@
+import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components';
+import { Collapsible } from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// No NavigationHeader here: the navigation header is hidden and
+// Collapsible.Header renders in place, overlaying the content (the default
+// `overlay` behavior of the low-level Header).
+export default function Screen() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamic: {
+ padding: 12,
+ },
+ boxRow: {
+ flexDirection: 'row',
+ gap: 6,
+ alignItems: 'stretch',
+ },
+});
+
+const content = generateContent({
+ count: 500,
+ backgroundColor: '#CBFCF5',
+ textColor: '#304077',
+});
diff --git a/example/src/app/collapsible-tabs-default-pager.tsx b/example/src/app/collapsible-tabs-default-pager.tsx
new file mode 100644
index 0000000..4e380b2
--- /dev/null
+++ b/example/src/app/collapsible-tabs-default-pager.tsx
@@ -0,0 +1,108 @@
+import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components';
+import { Collapsible, CollapsibleTabs } from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+type ListRow = {
+ index: number;
+ label: string;
+};
+
+const TABS = [
+ { name: 'cards', label: 'Cards' },
+ { name: 'list', label: 'FlatList' },
+];
+
+export default function Screen() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ header }} />}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {cards.map((row) => (
+
+ ))}
+
+
+
+ `${item.index}`}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamic: {
+ padding: 12,
+ },
+ boxRow: {
+ flexDirection: 'row',
+ gap: 6,
+ alignItems: 'stretch',
+ },
+ tabBar: {
+ backgroundColor: '#FFF',
+ borderTopWidth: 1,
+ borderTopColor: '#EEE',
+ },
+ tabLabel: {
+ color: '#304077',
+ },
+});
+
+const cards: ListRow[] = Array.from({ length: 500 }, (_, k) => ({
+ index: k + 1,
+ label: 'Cards',
+}));
+const rows: ListRow[] = Array.from({ length: 500 }, (_, k) => ({
+ index: k + 1,
+ label: 'FlatList',
+}));
diff --git a/example/src/app/collapsible-tabs.tsx b/example/src/app/collapsible-tabs.tsx
new file mode 100644
index 0000000..8bc1ee5
--- /dev/null
+++ b/example/src/app/collapsible-tabs.tsx
@@ -0,0 +1,93 @@
+import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components';
+import {
+ Collapsible,
+ CollapsibleTabs,
+ createPagerViewAdapter,
+} from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet } from 'react-native';
+import PagerView from 'react-native-pager-view';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+const pagerAdapter = createPagerViewAdapter(PagerView);
+
+const TABS = [
+ { name: 'a', label: 'Page A' },
+ { name: 'b', label: 'Page B' },
+];
+
+export default function Screen() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ header }} />}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {contentA}
+
+
+ {contentB}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamic: {
+ padding: 12,
+ },
+ boxRow: {
+ flexDirection: 'row',
+ gap: 6,
+ alignItems: 'stretch',
+ },
+ tabBar: {
+ backgroundColor: '#FFF',
+ borderTopWidth: 1,
+ borderTopColor: '#EEE',
+ },
+ tabLabel: {
+ color: '#304077',
+ },
+});
+
+const contentA = generateContent({
+ count: 500,
+ backgroundColor: '#E3CBFC',
+ textColor: '#304077',
+ label: 'Page A',
+});
+const contentB = generateContent({
+ count: 500,
+ backgroundColor: '#CBE3FC',
+ textColor: '#304077',
+ label: 'Page B',
+});
diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx
index 5447cce..5a99013 100644
--- a/example/src/app/index.tsx
+++ b/example/src/app/index.tsx
@@ -13,6 +13,32 @@ interface ShowcaseSection {
}
const SECTIONS: ShowcaseSection[] = [
+ {
+ title: 'High-level API',
+ data: [
+ { title: 'Preset: collapse', href: '/preset-collapse', icon: '🎁' },
+ { title: 'Preset: fade', href: '/preset-fade', icon: '🌫️' },
+ { title: 'Preset: parallax', href: '/preset-parallax', icon: '🎢' },
+ { title: 'Preset: scale', href: '/preset-scale', icon: '🔍' },
+ { title: 'Preset: none', href: '/preset-none', icon: '⬜' },
+ { title: 'Custom preset (3D fold)', href: '/preset-custom', icon: '🧪' },
+ {
+ title: 'Overlay header (no NavigationHeader)',
+ href: '/collapsible-overlay',
+ icon: '🪟',
+ },
+ {
+ title: 'CollapsibleTabs (pager-view)',
+ href: '/collapsible-tabs',
+ icon: '🗂️',
+ },
+ {
+ title: 'CollapsibleTabs (built-in pager)',
+ href: '/collapsible-tabs-default-pager',
+ icon: '📚',
+ },
+ ],
+ },
{
title: 'Core',
data: [
diff --git a/example/src/app/preset-collapse.tsx b/example/src/app/preset-collapse.tsx
new file mode 100644
index 0000000..a8433ef
--- /dev/null
+++ b/example/src/app/preset-collapse.tsx
@@ -0,0 +1,12 @@
+import { PresetShowcaseScreen } from '@/components';
+
+export default function Screen() {
+ return (
+
+ );
+}
diff --git a/example/src/app/preset-custom.tsx b/example/src/app/preset-custom.tsx
new file mode 100644
index 0000000..5712964
--- /dev/null
+++ b/example/src/app/preset-custom.tsx
@@ -0,0 +1,82 @@
+import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components';
+import {
+ Collapsible,
+ createCollapsiblePreset,
+} from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet } from 'react-native';
+import { interpolateColor } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// A custom preset is a worklet from motion state to per-part styles. This one
+// folds the collapsing content backwards in 3D, fades it, and darkens the
+// header background while it collapses.
+const fold = createCollapsiblePreset(({ progress, progressThreshold }) => {
+ 'worklet';
+ return {
+ header: {
+ backgroundColor: interpolateColor(
+ progress,
+ [0, 1],
+ ['#304077', '#0E1A40']
+ ),
+ },
+ dynamicContent: {
+ opacity: 1 - progress,
+ transform: [
+ { perspective: 600 },
+ { rotateX: `${progress * 60}deg` },
+ { translateY: progress * progressThreshold * 0.25 },
+ ],
+ },
+ };
+});
+
+export default function Screen() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ header }} />}
+ >
+
+
+
+
+
+
+
+
+ {content}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamic: {
+ padding: 12,
+ },
+ boxRow: {
+ flexDirection: 'row',
+ gap: 6,
+ alignItems: 'stretch',
+ },
+});
+
+const content = generateContent({
+ count: 500,
+ backgroundColor: '#CBCFFC',
+ textColor: '#304077',
+});
diff --git a/example/src/app/preset-fade.tsx b/example/src/app/preset-fade.tsx
new file mode 100644
index 0000000..3c9ba09
--- /dev/null
+++ b/example/src/app/preset-fade.tsx
@@ -0,0 +1,12 @@
+import { PresetShowcaseScreen } from '@/components';
+
+export default function Screen() {
+ return (
+
+ );
+}
diff --git a/example/src/app/preset-none.tsx b/example/src/app/preset-none.tsx
new file mode 100644
index 0000000..5e5d18d
--- /dev/null
+++ b/example/src/app/preset-none.tsx
@@ -0,0 +1,13 @@
+import { PresetShowcaseScreen } from '@/components';
+
+export default function Screen() {
+ return (
+
+ );
+}
diff --git a/example/src/app/preset-parallax.tsx b/example/src/app/preset-parallax.tsx
new file mode 100644
index 0000000..61a06ea
--- /dev/null
+++ b/example/src/app/preset-parallax.tsx
@@ -0,0 +1,12 @@
+import { PresetShowcaseScreen } from '@/components';
+
+export default function Screen() {
+ return (
+
+ );
+}
diff --git a/example/src/app/preset-scale.tsx b/example/src/app/preset-scale.tsx
new file mode 100644
index 0000000..be077a3
--- /dev/null
+++ b/example/src/app/preset-scale.tsx
@@ -0,0 +1,12 @@
+import { PresetShowcaseScreen } from '@/components';
+
+export default function Screen() {
+ return (
+
+ );
+}
diff --git a/example/src/components/PresetShowcaseScreen.tsx b/example/src/components/PresetShowcaseScreen.tsx
new file mode 100644
index 0000000..71414c8
--- /dev/null
+++ b/example/src/components/PresetShowcaseScreen.tsx
@@ -0,0 +1,86 @@
+import {
+ Collapsible,
+ type CollapsiblePresetInput,
+} from 'react-native-header-motion';
+import { Stack } from 'expo-router';
+import { StyleSheet, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { DynamicBox } from './DynamicBox';
+import { TitleWithSubtitle } from './TitleWithSubtitle';
+import { generateContent } from './generateContent';
+
+interface PresetShowcaseScreenProps {
+ title: string;
+ subtitle: string;
+ preset: CollapsiblePresetInput;
+ /**
+ * Renders the title as a `Collapsible.Pinned` section. Disable to move the
+ * title into the collapsing section instead (used by the `none` preset,
+ * where the whole header slides away).
+ */
+ withPinnedTitle?: boolean;
+ contentBackgroundColor?: string;
+}
+
+export function PresetShowcaseScreen({
+ title,
+ subtitle,
+ preset,
+ withPinnedTitle = true,
+ contentBackgroundColor = '#E3CBFC',
+}: PresetShowcaseScreenProps) {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ header }} />}
+ >
+ {withPinnedTitle ? (
+
+
+
+ ) : null}
+
+ {withPinnedTitle ? null : (
+
+ )}
+
+
+
+
+
+
+
+ {generateContent({
+ count: 500,
+ backgroundColor: contentBackgroundColor,
+ textColor: '#304077',
+ })}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ backgroundColor: '#304077',
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.1)',
+ },
+ dynamic: {
+ padding: 12,
+ },
+ dynamicContent: {
+ gap: 12,
+ },
+ boxRow: {
+ flexDirection: 'row',
+ gap: 6,
+ alignItems: 'stretch',
+ },
+});
diff --git a/example/src/components/index.ts b/example/src/components/index.ts
index 26cec60..b1ff7c8 100644
--- a/example/src/components/index.ts
+++ b/example/src/components/index.ts
@@ -1,4 +1,5 @@
export { ContentCard } from './ContentCard';
+export { PresetShowcaseScreen } from './PresetShowcaseScreen';
export { DynamicBox } from './DynamicBox';
export { generateContent } from './generateContent';
export { ShowcaseCollapsibleHeader } from './ShowcaseCollapsibleHeader';
diff --git a/src/collapsible/Collapsible.tsx b/src/collapsible/Collapsible.tsx
new file mode 100644
index 0000000..c9698e5
--- /dev/null
+++ b/src/collapsible/Collapsible.tsx
@@ -0,0 +1,360 @@
+import {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+import { StyleSheet, type ViewProps, type ViewStyle } from 'react-native';
+import Animated, {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ type AnimatedProps,
+} from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+import { Bridge } from '../components/Bridge';
+import { FlatList } from '../components/FlatList';
+import { Header, type HeaderProps } from '../components/Header';
+import { HeaderDynamic } from '../components/HeaderDynamic';
+import {
+ HeaderMotionContextProvider,
+ type HeaderMotionProps,
+} from '../components/HeaderMotion';
+import { NavigationBridge } from '../components/NavigationBridge';
+import { ScrollView } from '../components/ScrollView';
+import { useMotionProgress } from '../hooks/useMotionProgress';
+import type { HeaderDynamicProps } from '../types';
+import {
+ CollapsiblePresetsContext,
+ CollapsibleTabsContext,
+ useCollapsiblePresetsOrThrow,
+} from './context';
+import {
+ resolveCollapsiblePartStyle,
+ resolveCollapsiblePresets,
+} from './presets';
+import type {
+ CollapsibleHeaderState,
+ CollapsiblePresetContext,
+ CollapsiblePresetInput,
+} from './types';
+
+type DistributiveOmit = T extends any
+ ? Omit
+ : never;
+
+const EXPANDED_EDGE = 0.001;
+const COLLAPSED_EDGE = 0.999;
+
+export interface CollapsibleProps
+ extends HeaderMotionProps {
+ /**
+ * How the collapsing content should animate: a built-in preset name, a
+ * configured preset from `CollapsiblePresets`, a custom preset created with
+ * `createCollapsiblePreset()`, or an array of those to combine.
+ *
+ * @default 'collapse'
+ */
+ preset?: CollapsiblePresetInput;
+ /**
+ * Called when the header settles into a terminal state: `'collapsed'` when
+ * `progress` reaches `1`, `'expanded'` when it returns to `0`.
+ */
+ onStateChange?: (state: CollapsibleHeaderState) => void;
+}
+
+function CollapsibleStateObserver({
+ onStateChange,
+}: {
+ onStateChange: (state: CollapsibleHeaderState) => void;
+}) {
+ const { progress } = useMotionProgress();
+ const callbackRef = useRef(onStateChange);
+ useEffect(() => {
+ callbackRef.current = onStateChange;
+ });
+
+ const emit = useCallback((state: CollapsibleHeaderState) => {
+ callbackRef.current?.(state);
+ }, []);
+
+ const lastState = useSharedValue('expanded');
+
+ useAnimatedReaction(
+ () => progress.get(),
+ (value) => {
+ const previous = lastState.get();
+ if (value >= COLLAPSED_EDGE && previous !== 'collapsed') {
+ lastState.set('collapsed');
+ scheduleOnRN(emit, 'collapsed');
+ } else if (value <= EXPANDED_EDGE && previous !== 'expanded') {
+ lastState.set('expanded');
+ scheduleOnRN(emit, 'expanded');
+ }
+ }
+ );
+
+ return null;
+}
+
+/**
+ * High-level root for a collapsible header setup.
+ *
+ * It renders a `HeaderMotion` provider and shares the resolved `preset` with
+ * the `Collapsible.*` parts composed inside — no visual output of its own.
+ *
+ * @template T - The type of scroll ID string
+ */
+function CollapsibleRoot({
+ preset = 'collapse',
+ onStateChange,
+ children,
+ ...headerMotionProps
+}: CollapsibleProps) {
+ const presets = useMemo(() => resolveCollapsiblePresets(preset), [preset]);
+
+ return (
+
+
+ {onStateChange ? (
+
+ ) : null}
+ {children}
+
+
+ );
+}
+
+export type CollapsibleHeaderProps = DistributiveOmit<
+ Extract,
+ 'asChild'
+>;
+
+/**
+ * The header frame of a collapsible setup.
+ *
+ * Renders `HeaderMotion.Header` (so the total header height is measured
+ * automatically) and slides itself up by the collapse distance as the user
+ * scrolls. Preset `header` styles are merged on top of that intrinsic
+ * transform.
+ */
+function CollapsibleHeaderPart({ style, ...rest }: CollapsibleHeaderProps) {
+ const presets = useCollapsiblePresetsOrThrow('Collapsible.Header');
+ const { progress, progressThreshold } = useMotionProgress();
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const context: CollapsiblePresetContext = {
+ progress: progress.get(),
+ progressThreshold: progressThreshold.get(),
+ };
+ const intrinsic: ViewStyle = {
+ transform: [
+ { translateY: -context.progress * context.progressThreshold },
+ ],
+ };
+
+ return resolveCollapsiblePartStyle('header', intrinsic, presets, context);
+ });
+
+ return ;
+}
+
+export type CollapsiblePinnedProps = AnimatedProps;
+
+/**
+ * A header section that stays visually in place while the header collapses.
+ *
+ * Use it for content that must remain visible — a title row, actions, or a
+ * search field. It counter-translates against the header frame's slide, and
+ * preset `pinned` styles are merged on top.
+ */
+function CollapsiblePinned({ style, ...rest }: CollapsiblePinnedProps) {
+ const presets = useCollapsiblePresetsOrThrow('Collapsible.Pinned');
+ const { progress, progressThreshold } = useMotionProgress();
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const context: CollapsiblePresetContext = {
+ progress: progress.get(),
+ progressThreshold: progressThreshold.get(),
+ };
+ const intrinsic: ViewStyle = {
+ transform: [{ translateY: context.progress * context.progressThreshold }],
+ };
+
+ return resolveCollapsiblePartStyle('pinned', intrinsic, presets, context);
+ });
+
+ return ;
+}
+
+export type CollapsibleDynamicProps = DistributiveOmit<
+ Extract,
+ 'asChild'
+> & {
+ /** Style for the inner content view that the preset effects animate. */
+ contentStyle?: AnimatedProps['style'];
+};
+
+const dynamicStyles = StyleSheet.create({
+ clip: {
+ overflow: 'hidden',
+ },
+});
+
+/**
+ * The collapsing section of the header.
+ *
+ * Renders `HeaderMotion.Header.Dynamic`, so its measured height defines the
+ * collapse distance. The children are wrapped in an inner view that receives
+ * the preset's content effect (fade, parallax, scale, ...), while this
+ * wrapper clips the content (`overflow: 'hidden'`) and receives the preset's
+ * `dynamic` styles.
+ */
+function CollapsibleDynamic({
+ style,
+ contentStyle,
+ children,
+ ...rest
+}: CollapsibleDynamicProps) {
+ const presets = useCollapsiblePresetsOrThrow('Collapsible.Dynamic');
+ const { progress, progressThreshold } = useMotionProgress();
+
+ const wrapperStyle = useAnimatedStyle(() => {
+ const context: CollapsiblePresetContext = {
+ progress: progress.get(),
+ progressThreshold: progressThreshold.get(),
+ };
+
+ return resolveCollapsiblePartStyle('dynamic', undefined, presets, context);
+ });
+
+ const innerStyle = useAnimatedStyle(() => {
+ const context: CollapsiblePresetContext = {
+ progress: progress.get(),
+ progressThreshold: progressThreshold.get(),
+ };
+
+ return resolveCollapsiblePartStyle(
+ 'dynamicContent',
+ undefined,
+ presets,
+ context
+ );
+ });
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export interface CollapsibleNavigationHeaderRenderProps {
+ /**
+ * Places the finished header element into your navigator.
+ *
+ * The element already carries the HeaderMotion, preset, and tabs contexts
+ * across the tree boundary, so it can be rendered anywhere — most commonly
+ * inside a navigation library's `header` option.
+ */
+ render: (header: ReactElement) => ReactNode;
+}
+
+export type CollapsibleNavigationHeaderProps = CollapsibleHeaderProps &
+ CollapsibleNavigationHeaderRenderProps;
+
+/**
+ * A collapsible header rendered by a navigation library.
+ *
+ * Compose the header content as children exactly like in `Collapsible.Header`
+ * and use `render` to mount the prepared element into your navigator. This
+ * replaces the manual `Bridge` / `NavigationBridge` wiring of the low-level
+ * API.
+ *
+ * @example
+ * ```tsx
+ * header }} />}
+ * >
+ * {...}
+ * {...}
+ *
+ * ```
+ */
+function CollapsibleNavigationHeader({
+ render,
+ ...headerProps
+}: CollapsibleNavigationHeaderProps) {
+ const presets = useCollapsiblePresetsOrThrow('Collapsible.NavigationHeader');
+ const tabsContext = useContext(CollapsibleTabsContext);
+
+ return (
+
+ {(value) =>
+ render(
+
+
+
+
+
+
+
+ )
+ }
+
+ );
+}
+
+/**
+ * High-level, preset-driven collapsible header built on top of the
+ * `HeaderMotion` primitives.
+ *
+ * Compose the header from explicit parts — the same composition pattern as
+ * the low-level API, with the animation choreography handled for you:
+ *
+ * @example
+ * ```tsx
+ *
+ * header }} />}
+ * >
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * {content}
+ *
+ * ```
+ */
+export const Collapsible = Object.assign(CollapsibleRoot, {
+ /**
+ * Header frame: measures the total header height and slides up while
+ * collapsing. Compose `Collapsible.Pinned` and `Collapsible.Dynamic`
+ * (plus any plain views) inside it.
+ */
+ Header: CollapsibleHeaderPart,
+ /** Header section that stays visually in place while the header collapses. */
+ Pinned: CollapsiblePinned,
+ /**
+ * The collapsing header section. Its measured height defines the collapse
+ * distance and the active preset animates its content.
+ */
+ Dynamic: CollapsibleDynamic,
+ /**
+ * Navigation-rendered variant of `Collapsible.Header` — bridges every
+ * collapsible context across the React tree boundary for you.
+ */
+ NavigationHeader: CollapsibleNavigationHeader,
+ /** Pre-wired `Animated.ScrollView` — the same component as `HeaderMotion.ScrollView`. */
+ ScrollView: ScrollView,
+ /** Pre-wired `Animated.FlatList` — the same component as `HeaderMotion.FlatList`. */
+ FlatList: FlatList,
+});
diff --git a/src/collapsible/CollapsibleTabs.tsx b/src/collapsible/CollapsibleTabs.tsx
new file mode 100644
index 0000000..a893061
--- /dev/null
+++ b/src/collapsible/CollapsibleTabs.tsx
@@ -0,0 +1,402 @@
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+import {
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+ type StyleProp,
+ type TextStyle,
+ type ViewStyle,
+} from 'react-native';
+import { HeaderMotionScrollIdContext } from '../context';
+import { useActiveScrollId } from '../hooks/useActiveScrollId';
+import { Collapsible, type CollapsibleProps } from './Collapsible';
+import {
+ CollapsibleTabsContext,
+ useCollapsibleTabsContextOrThrow,
+ type CollapsibleTabsContextValue,
+} from './context';
+import {
+ DefaultCollapsibleTabsPagerAdapter,
+ type CollapsibleTabsPagerAdapter,
+ type CollapsibleTabsPagerController,
+} from './pagerAdapters';
+
+/** A tab declaration: a plain name, or a name with a display label. */
+export type CollapsibleTabsTabInput = string | { name: string; label?: string };
+
+export interface CollapsibleTabsProps
+ extends Omit, 'activeScrollId'> {
+ /**
+ * The tabs, in pager order. Each entry is a name or `{ name, label }` —
+ * the label is what `CollapsibleTabs.Bar` displays and falls back to the
+ * name.
+ *
+ * The names must match the `name` props of the `CollapsibleTabs.Tab`
+ * children rendered inside `CollapsibleTabs.Pager`, in the same order.
+ */
+ tabs: readonly CollapsibleTabsTabInput[];
+ /**
+ * Name of the initially active tab.
+ *
+ * @default the first entry of `tabs`
+ */
+ initialTab?: string;
+ /** Called whenever the active tab changes, from a swipe or `goTo`. */
+ onTabChange?: (name: string) => void;
+}
+
+/**
+ * High-level root for one collapsible header shared across multiple tabs.
+ *
+ * It renders a `Collapsible` root and owns the active-tab state: the
+ * `activeScrollId` wiring, the pager coordination, and the tab bar state all
+ * come from here. Each `CollapsibleTabs.Tab` provides its name as the default
+ * `scrollId`, so the scrollables inside need no manual wiring.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * {about}
+ *
+ *
+ *
+ * ```
+ */
+function CollapsibleTabsRoot({
+ tabs,
+ initialTab,
+ onTabChange,
+ children,
+ ...collapsibleProps
+}: CollapsibleTabsProps) {
+ const normalizedTabs = useMemo(
+ () =>
+ tabs.map((tab) =>
+ typeof tab === 'string'
+ ? { name: tab, label: tab }
+ : { name: tab.name, label: tab.label ?? tab.name }
+ ),
+ [tabs]
+ );
+
+ if (normalizedTabs.length === 0) {
+ throw new Error(
+ '[react-native-header-motion] CollapsibleTabs requires at least one tab.'
+ );
+ }
+
+ // The initial tab is resolved once — later `initialTab` changes must not
+ // re-run it, mirroring how pagers treat their `initialPage`.
+ const initialRef = useRef<{ name: string; index: number } | null>(null);
+ if (initialRef.current === null) {
+ let index = initialTab
+ ? normalizedTabs.findIndex((tab) => tab.name === initialTab)
+ : 0;
+ if (index < 0) {
+ if (__DEV__) {
+ console.warn(
+ `[react-native-header-motion] CollapsibleTabs: initialTab "${initialTab}" is not in \`tabs\`. Falling back to the first tab.`
+ );
+ }
+ index = 0;
+ }
+
+ initialRef.current = { name: normalizedTabs[index]!.name, index };
+ }
+
+ const [activeScrollId, setActiveScrollId] = useActiveScrollId(
+ initialRef.current.name
+ );
+ const controllerRef = useRef(null);
+
+ const onTabChangeRef = useRef(onTabChange);
+ useEffect(() => {
+ onTabChangeRef.current = onTabChange;
+ });
+
+ const activeTab = activeScrollId.state;
+
+ const setActiveTab = useCallback(
+ (name: string) => {
+ setActiveScrollId(name);
+ onTabChangeRef.current?.(name);
+ },
+ [setActiveScrollId]
+ );
+
+ const goTo = useCallback(
+ (name: string) => {
+ const index = normalizedTabs.findIndex((tab) => tab.name === name);
+ if (index < 0) {
+ if (__DEV__) {
+ console.warn(
+ `[react-native-header-motion] CollapsibleTabs: unknown tab "${name}".`
+ );
+ }
+ return;
+ }
+
+ controllerRef.current?.setIndex(index);
+ if (name !== activeTab) {
+ setActiveTab(name);
+ }
+ },
+ [normalizedTabs, activeTab, setActiveTab]
+ );
+
+ const onPagerIndexChange = useCallback(
+ (index: number) => {
+ const tab = normalizedTabs[index];
+ if (!tab || tab.name === activeTab) {
+ return;
+ }
+
+ setActiveTab(tab.name);
+ },
+ [normalizedTabs, activeTab, setActiveTab]
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ tabs: normalizedTabs,
+ activeTab,
+ goTo,
+ initialIndex: initialRef.current!.index,
+ controllerRef,
+ onPagerIndexChange,
+ }),
+ [normalizedTabs, activeTab, goTo, onPagerIndexChange]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export interface CollapsibleTabsPagerProps {
+ /**
+ * The pager engine to render with.
+ *
+ * Defaults to the dependency-free built-in (a paging horizontal
+ * `ScrollView`). Pass `createPagerViewAdapter(PagerView)` for
+ * `react-native-pager-view`, or any custom `CollapsibleTabsPagerAdapter`.
+ */
+ adapter?: CollapsibleTabsPagerAdapter;
+ /** Style for the pager container. Defaults to `flex: 1`. */
+ style?: StyleProp;
+ /** `CollapsibleTabs.Tab` children, in the same order as the `tabs` prop. */
+ children: ReactNode;
+}
+
+/**
+ * Renders the swipeable pages of a `CollapsibleTabs` setup through a pager
+ * adapter and keeps the pager in sync with the active tab.
+ */
+function CollapsibleTabsPager({
+ adapter: Adapter = DefaultCollapsibleTabsPagerAdapter,
+ style,
+ children,
+}: CollapsibleTabsPagerProps) {
+ const { tabs, initialIndex, controllerRef, onPagerIndexChange } =
+ useCollapsibleTabsContextOrThrow('CollapsibleTabs.Pager');
+
+ const pages = useMemo(() => {
+ const elements: ReactElement[] = [];
+ const names: (string | undefined)[] = [];
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) {
+ if (child != null && __DEV__) {
+ console.warn(
+ '[react-native-header-motion] CollapsibleTabs.Pager only renders children.'
+ );
+ }
+ return;
+ }
+
+ const name = (child.props as { name?: string }).name;
+ names.push(name);
+ elements.push(
+ cloneElement(child, { key: name ?? `tab-${elements.length}` })
+ );
+ });
+
+ if (__DEV__) {
+ const expected = tabs.map((tab) => tab.name);
+ const matches =
+ expected.length === names.length &&
+ expected.every((name, index) => name === names[index]);
+ if (!matches) {
+ console.warn(
+ `[react-native-header-motion] CollapsibleTabs.Pager children do not match the \`tabs\` prop. Expected [${expected.join(
+ ', '
+ )}], found [${names.join(', ')}].`
+ );
+ }
+ }
+
+ return elements;
+ }, [children, tabs]);
+
+ return (
+
+ {pages}
+
+ );
+}
+
+export interface CollapsibleTabProps {
+ /**
+ * The tab's name from the `tabs` prop. It doubles as the default `scrollId`
+ * for every header-motion scrollable rendered inside.
+ */
+ name: string;
+ /** Style for the page container. */
+ style?: StyleProp;
+ /** The page content, typically a header-motion scrollable. */
+ children?: ReactNode;
+}
+
+const tabStyles = StyleSheet.create({
+ tab: {
+ flex: 1,
+ },
+});
+
+/**
+ * One page of a `CollapsibleTabs.Pager`.
+ *
+ * Provides its `name` as the default `scrollId`, so any header-motion
+ * scrollable inside — `Collapsible.ScrollView`, `Collapsible.FlatList`, or a
+ * custom one from `createHeaderMotionScrollable()` — participates in the
+ * shared header state without extra wiring.
+ */
+function CollapsibleTab({ name, style, children }: CollapsibleTabProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export interface CollapsibleTabsBarProps {
+ /** Style for the bar container. */
+ style?: StyleProp;
+ /** Style for each tab press target. */
+ tabStyle?: StyleProp;
+ /** Style for every tab label. */
+ labelStyle?: StyleProp;
+ /** Style merged onto the active tab's label. */
+ activeLabelStyle?: StyleProp;
+}
+
+const barStyles = StyleSheet.create({
+ bar: {
+ flexDirection: 'row',
+ },
+ tab: {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ label: {
+ fontSize: 14,
+ fontWeight: '500',
+ opacity: 0.6,
+ },
+ activeLabel: {
+ fontWeight: '700',
+ opacity: 1,
+ },
+});
+
+/**
+ * Minimal, unstyled-by-design tab bar for `CollapsibleTabs`.
+ *
+ * Place it anywhere inside the header (or outside it). For a fully custom tab
+ * bar, build your own component on top of `useCollapsibleTabs()` instead.
+ */
+function CollapsibleTabsBar({
+ style,
+ tabStyle,
+ labelStyle,
+ activeLabelStyle,
+}: CollapsibleTabsBarProps) {
+ const { tabs, activeTab, goTo } = useCollapsibleTabsContextOrThrow(
+ 'CollapsibleTabs.Bar'
+ );
+
+ return (
+
+ {tabs.map((tab) => {
+ const isActive = tab.name === activeTab;
+
+ return (
+ goTo(tab.name)}
+ >
+
+ {tab.label}
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Compound entrypoint for tabbed collapsible headers. See
+ * `CollapsibleTabsRoot` for the full example.
+ */
+export const CollapsibleTabs = Object.assign(CollapsibleTabsRoot, {
+ /** Renders the swipeable pages through a pager adapter. */
+ Pager: CollapsibleTabsPager,
+ /** One page; provides its `name` as the default `scrollId`. */
+ Tab: CollapsibleTab,
+ /** Minimal default tab bar. Build custom bars with `useCollapsibleTabs()`. */
+ Bar: CollapsibleTabsBar,
+});
diff --git a/src/collapsible/__tests__/Collapsible.test.tsx b/src/collapsible/__tests__/Collapsible.test.tsx
new file mode 100644
index 0000000..548cef6
--- /dev/null
+++ b/src/collapsible/__tests__/Collapsible.test.tsx
@@ -0,0 +1,227 @@
+const mockUseCollapsiblePresetsOrThrow = jest.fn();
+const mockUseMotionProgress = jest.fn();
+const mockUseHeaderMotionBridge = jest.fn();
+const mockUseContext = jest.fn();
+
+jest.mock('react', () => {
+ const ReactActual = jest.requireActual('react');
+
+ return {
+ ...ReactActual,
+ useMemo: (factory: () => unknown) => factory(),
+ useCallback: (callback: unknown) => callback,
+ useRef: (initial: unknown) => ({ current: initial }),
+ useEffect: jest.fn(),
+ useContext: (...args: any[]) => mockUseContext(...args),
+ };
+});
+
+jest.mock('../context', () => {
+ const actual = jest.requireActual('../context');
+
+ return {
+ __esModule: true,
+ ...actual,
+ useCollapsiblePresetsOrThrow: (...args: any[]) =>
+ mockUseCollapsiblePresetsOrThrow(...args),
+ };
+});
+
+jest.mock('../../hooks/useMotionProgress', () => ({
+ __esModule: true,
+ useMotionProgress: (...args: any[]) => mockUseMotionProgress(...args),
+}));
+
+jest.mock('../../hooks/useHeaderMotionBridge', () => ({
+ __esModule: true,
+ useHeaderMotionBridge: (...args: any[]) => mockUseHeaderMotionBridge(...args),
+}));
+
+jest.mock('react-native-gesture-handler', () => {
+ const ReactActual = jest.requireActual('react');
+ const pan = {
+ enabled: () => pan,
+ onChange: () => pan,
+ onEnd: () => pan,
+ shouldCancelWhenOutside: () => pan,
+ };
+
+ return {
+ __esModule: true,
+ Gesture: {
+ Pan: () => pan,
+ },
+ GestureDetector: ({ children }: any) =>
+ ReactActual.createElement('GestureDetector', null, children),
+ GestureHandlerRootView: ({ children }: any) =>
+ ReactActual.createElement('GestureHandlerRootView', null, children),
+ };
+});
+
+import React from 'react';
+import Animated from 'react-native-reanimated';
+import { Bridge } from '../../components/Bridge';
+import { Header } from '../../components/Header';
+import { HeaderDynamic } from '../../components/HeaderDynamic';
+import { HeaderMotionContextProvider } from '../../components/HeaderMotion';
+import { NavigationBridge } from '../../components/NavigationBridge';
+import { Collapsible } from '../Collapsible';
+import { CollapsiblePresetsContext, CollapsibleTabsContext } from '../context';
+import { resolveCollapsiblePresets } from '../presets';
+
+function createSharedValue(value: T) {
+ return {
+ get: jest.fn(() => value),
+ set: jest.fn(),
+ value,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ modify: jest.fn(),
+ } as any;
+}
+
+const motionProgress = {
+ progress: createSharedValue(0.5),
+ progressThreshold: createSharedValue(100),
+};
+
+describe('Collapsible', () => {
+ beforeEach(() => {
+ mockUseCollapsiblePresetsOrThrow.mockReset();
+ mockUseMotionProgress.mockReset();
+ mockUseHeaderMotionBridge.mockReset();
+ mockUseContext.mockReset();
+ mockUseMotionProgress.mockReturnValue(motionProgress);
+ mockUseCollapsiblePresetsOrThrow.mockReturnValue(
+ resolveCollapsiblePresets('collapse')
+ );
+ });
+
+ it('root renders the HeaderMotion provider and shares resolved presets', () => {
+ const children = React.createElement('Child');
+ const element = Collapsible({
+ progressThreshold: 120,
+ children,
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(HeaderMotionContextProvider);
+ expect(element.props.progressThreshold).toBe(120);
+
+ const presetsProvider = element.props.children;
+ expect(presetsProvider.type).toBe(CollapsiblePresetsContext.Provider);
+ expect(presetsProvider.props.value).toHaveLength(1);
+ // No onStateChange -> no state observer rendered.
+ expect(presetsProvider.props.children[0]).toBeNull();
+ expect(presetsProvider.props.children[1]).toBe(children);
+ });
+
+ it('root renders a state observer when onStateChange is provided', () => {
+ const element = Collapsible({
+ onStateChange: jest.fn(),
+ children: null,
+ }) as React.ReactElement;
+
+ expect(element.props.children.props.children[0]).not.toBeNull();
+ });
+
+ it('Header slides up by the collapse distance', () => {
+ const userStyle = { backgroundColor: 'red' };
+ const element = Collapsible.Header({
+ style: userStyle,
+ children: React.createElement('Child'),
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(Header);
+ const [style, animatedStyle] = element.props.style;
+ expect(style).toBe(userStyle);
+ expect(animatedStyle).toEqual({ transform: [{ translateY: -50 }] });
+ });
+
+ it('Header merges preset header styles on top of the intrinsic slide', () => {
+ mockUseCollapsiblePresetsOrThrow.mockReturnValue([
+ () => ({ header: { opacity: 0.5 } }),
+ ]);
+
+ const element = Collapsible.Header({
+ children: null,
+ }) as React.ReactElement;
+ const [, animatedStyle] = element.props.style;
+
+ expect(animatedStyle).toEqual({
+ transform: [{ translateY: -50 }],
+ opacity: 0.5,
+ });
+ });
+
+ it('Pinned counter-translates to stay in place', () => {
+ const element = Collapsible.Pinned({
+ children: null,
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(Animated.View);
+ const [, animatedStyle] = element.props.style;
+ expect(animatedStyle).toEqual({ transform: [{ translateY: 50 }] });
+ });
+
+ it('Dynamic clips its wrapper and animates the content with the preset', () => {
+ const wrapperUserStyle = { padding: 12 };
+ const contentStyle = { gap: 6 };
+ const element = Collapsible.Dynamic({
+ style: wrapperUserStyle,
+ contentStyle,
+ children: React.createElement('Child'),
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(HeaderDynamic);
+ const [clipStyle, style, wrapperStyle] = element.props.style;
+ expect(clipStyle).toEqual({ overflow: 'hidden' });
+ expect(style).toBe(wrapperUserStyle);
+ expect(wrapperStyle).toEqual({ transform: [{ translateY: 50 }] });
+
+ const inner = element.props.children;
+ expect(inner.type).toBe(Animated.View);
+ expect(inner.props.style[0]).toBe(contentStyle);
+ expect(inner.props.style[1]).toEqual({
+ transform: [{ translateY: -50 }],
+ });
+ });
+
+ it('NavigationHeader bridges the contexts and hands the header to render', () => {
+ const presets = resolveCollapsiblePresets('fade');
+ const tabsContextValue = { activeTab: 'a' };
+ mockUseCollapsiblePresetsOrThrow.mockReturnValue(presets);
+ mockUseContext.mockReturnValue(tabsContextValue);
+
+ const render = jest.fn((_header: React.ReactElement) => 'rendered');
+ const element = Collapsible.NavigationHeader({
+ render,
+ children: React.createElement('Child'),
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(Bridge);
+ expect(mockUseContext).toHaveBeenCalledWith(CollapsibleTabsContext);
+
+ const bridgeValue = { progress: motionProgress.progress };
+ expect(element.props.children(bridgeValue)).toBe('rendered');
+
+ const headerElement = render.mock.calls[0]![0] as React.ReactElement;
+ expect(headerElement.type).toBe(NavigationBridge);
+ expect(headerElement.props.value).toBe(bridgeValue);
+
+ const presetsProvider = headerElement.props.children;
+ expect(presetsProvider.type).toBe(CollapsiblePresetsContext.Provider);
+ expect(presetsProvider.props.value).toBe(presets);
+
+ const tabsProvider = presetsProvider.props.children;
+ expect(tabsProvider.type).toBe(CollapsibleTabsContext.Provider);
+ expect(tabsProvider.props.value).toBe(tabsContextValue);
+ });
+
+ it('exposes the pre-wired scrollables as aliases', () => {
+ const { ScrollView } = jest.requireActual('../../components/ScrollView');
+ const { FlatList } = jest.requireActual('../../components/FlatList');
+
+ expect(Collapsible.ScrollView).toBe(ScrollView);
+ expect(Collapsible.FlatList).toBe(FlatList);
+ });
+});
diff --git a/src/collapsible/__tests__/CollapsibleTabs.test.tsx b/src/collapsible/__tests__/CollapsibleTabs.test.tsx
new file mode 100644
index 0000000..8090ff0
--- /dev/null
+++ b/src/collapsible/__tests__/CollapsibleTabs.test.tsx
@@ -0,0 +1,312 @@
+const mockUseActiveScrollId = jest.fn();
+const mockUseCollapsibleTabsContextOrThrow = jest.fn();
+
+jest.mock('react', () => {
+ const ReactActual = jest.requireActual('react');
+
+ return {
+ ...ReactActual,
+ useMemo: (factory: () => unknown) => factory(),
+ useCallback: (callback: unknown) => callback,
+ useRef: (initial: unknown) => ({ current: initial }),
+ useEffect: jest.fn(),
+ };
+});
+
+jest.mock('../context', () => {
+ const actual = jest.requireActual('../context');
+
+ return {
+ __esModule: true,
+ ...actual,
+ useCollapsibleTabsContextOrThrow: (...args: any[]) =>
+ mockUseCollapsibleTabsContextOrThrow(...args),
+ };
+});
+
+jest.mock('../../hooks/useActiveScrollId', () => ({
+ __esModule: true,
+ useActiveScrollId: (...args: any[]) => mockUseActiveScrollId(...args),
+}));
+
+jest.mock('react-native-gesture-handler', () => {
+ const ReactActual = jest.requireActual('react');
+ const pan = {
+ enabled: () => pan,
+ onChange: () => pan,
+ onEnd: () => pan,
+ shouldCancelWhenOutside: () => pan,
+ };
+
+ return {
+ __esModule: true,
+ Gesture: {
+ Pan: () => pan,
+ },
+ GestureDetector: ({ children }: any) =>
+ ReactActual.createElement('GestureDetector', null, children),
+ GestureHandlerRootView: ({ children }: any) =>
+ ReactActual.createElement('GestureHandlerRootView', null, children),
+ };
+});
+
+import React from 'react';
+import { Pressable, View } from 'react-native';
+import { HeaderMotionScrollIdContext } from '../../context';
+import { Collapsible } from '../Collapsible';
+import { CollapsibleTabs } from '../CollapsibleTabs';
+import { CollapsibleTabsContext } from '../context';
+import { DefaultCollapsibleTabsPagerAdapter } from '../pagerAdapters';
+
+const activeSv = { value: 'a' };
+let setActiveScrollId: jest.Mock;
+
+function mockActiveScroll(state: string) {
+ setActiveScrollId = jest.fn();
+ mockUseActiveScrollId.mockReturnValue([
+ { state, sv: activeSv },
+ setActiveScrollId,
+ ]);
+}
+
+function renderRoot(props: Record = {}) {
+ const element = CollapsibleTabs({
+ tabs: ['a', 'b'],
+ children: React.createElement('Child'),
+ ...props,
+ } as any) as React.ReactElement;
+
+ const tabsProvider = element.props.children;
+ return { element, tabsProvider, contextValue: tabsProvider.props.value };
+}
+
+describe('CollapsibleTabs root', () => {
+ beforeEach(() => {
+ mockUseActiveScrollId.mockReset();
+ mockUseCollapsibleTabsContextOrThrow.mockReset();
+ mockActiveScroll('a');
+ });
+
+ it('renders a Collapsible root wired to the active scroll id', () => {
+ const { element, tabsProvider, contextValue } = renderRoot({
+ preset: 'fade',
+ });
+
+ expect(element.type).toBe(Collapsible);
+ expect(element.props.activeScrollId).toBe(activeSv);
+ expect(element.props.preset).toBe('fade');
+ expect(mockUseActiveScrollId).toHaveBeenCalledWith('a');
+
+ expect(tabsProvider.type).toBe(CollapsibleTabsContext.Provider);
+ expect(contextValue.tabs).toEqual([
+ { name: 'a', label: 'a' },
+ { name: 'b', label: 'b' },
+ ]);
+ expect(contextValue.activeTab).toBe('a');
+ expect(contextValue.initialIndex).toBe(0);
+ });
+
+ it('normalizes labeled tab entries', () => {
+ const { contextValue } = renderRoot({
+ tabs: [{ name: 'a', label: 'Page A' }, { name: 'b' }],
+ });
+
+ expect(contextValue.tabs).toEqual([
+ { name: 'a', label: 'Page A' },
+ { name: 'b', label: 'b' },
+ ]);
+ });
+
+ it('resolves initialTab to the matching index', () => {
+ const { contextValue } = renderRoot({ initialTab: 'b' });
+
+ expect(mockUseActiveScrollId).toHaveBeenCalledWith('b');
+ expect(contextValue.initialIndex).toBe(1);
+ });
+
+ it('warns and falls back to the first tab for an unknown initialTab', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const { contextValue } = renderRoot({ initialTab: 'nope' });
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('initialTab "nope"')
+ );
+ expect(mockUseActiveScrollId).toHaveBeenCalledWith('a');
+ expect(contextValue.initialIndex).toBe(0);
+ warn.mockRestore();
+ });
+
+ it('throws without tabs', () => {
+ expect(() => renderRoot({ tabs: [] })).toThrow('at least one tab');
+ });
+
+ it('goTo moves the pager and activates the tab', () => {
+ const onTabChange = jest.fn();
+ const { contextValue } = renderRoot({ onTabChange });
+ const setIndex = jest.fn();
+ contextValue.controllerRef.current = { setIndex };
+
+ contextValue.goTo('b');
+
+ expect(setIndex).toHaveBeenCalledWith(1);
+ expect(setActiveScrollId).toHaveBeenCalledWith('b');
+ expect(onTabChange).toHaveBeenCalledWith('b');
+ });
+
+ it('goTo on the active tab still moves the pager but does not re-activate', () => {
+ const onTabChange = jest.fn();
+ const { contextValue } = renderRoot({ onTabChange });
+ const setIndex = jest.fn();
+ contextValue.controllerRef.current = { setIndex };
+
+ contextValue.goTo('a');
+
+ expect(setIndex).toHaveBeenCalledWith(0);
+ expect(setActiveScrollId).not.toHaveBeenCalled();
+ expect(onTabChange).not.toHaveBeenCalled();
+ });
+
+ it('goTo warns on unknown tabs', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const { contextValue } = renderRoot({});
+
+ contextValue.goTo('nope');
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('unknown tab "nope"')
+ );
+ expect(setActiveScrollId).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it('pager index changes activate the corresponding tab once', () => {
+ const onTabChange = jest.fn();
+ const { contextValue } = renderRoot({ onTabChange });
+
+ contextValue.onPagerIndexChange(1);
+ expect(setActiveScrollId).toHaveBeenCalledWith('b');
+ expect(onTabChange).toHaveBeenCalledWith('b');
+
+ setActiveScrollId.mockClear();
+ onTabChange.mockClear();
+ contextValue.onPagerIndexChange(0);
+ expect(setActiveScrollId).not.toHaveBeenCalled();
+ expect(onTabChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CollapsibleTabs.Pager', () => {
+ const tabsContext = () => ({
+ tabs: [
+ { name: 'a', label: 'a' },
+ { name: 'b', label: 'b' },
+ ],
+ activeTab: 'a',
+ goTo: jest.fn(),
+ initialIndex: 1,
+ controllerRef: { current: null },
+ onPagerIndexChange: jest.fn(),
+ });
+
+ beforeEach(() => {
+ mockUseCollapsibleTabsContextOrThrow.mockReset();
+ });
+
+ it('renders the tabs through the default adapter with keyed pages', () => {
+ const contextValue = tabsContext();
+ mockUseCollapsibleTabsContextOrThrow.mockReturnValue(contextValue);
+
+ const element = CollapsibleTabs.Pager({
+ children: [
+ React.createElement(CollapsibleTabs.Tab, { name: 'a' }),
+ React.createElement(CollapsibleTabs.Tab, { name: 'b' }),
+ ],
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(DefaultCollapsibleTabsPagerAdapter);
+ expect(element.props.initialIndex).toBe(1);
+ expect(element.props.controllerRef).toBe(contextValue.controllerRef);
+ expect(element.props.onIndexChange).toBe(contextValue.onPagerIndexChange);
+
+ const pages = element.props.children;
+ expect(pages).toHaveLength(2);
+ expect(pages[0].key).toBe('a');
+ expect(pages[1].key).toBe('b');
+ });
+
+ it('renders through a custom adapter when provided', () => {
+ mockUseCollapsibleTabsContextOrThrow.mockReturnValue({
+ ...tabsContext(),
+ tabs: [{ name: 'a', label: 'a' }],
+ });
+ const CustomAdapter = jest.fn();
+
+ const element = CollapsibleTabs.Pager({
+ adapter: CustomAdapter,
+ children: React.createElement(CollapsibleTabs.Tab, { name: 'a' }),
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(CustomAdapter);
+ });
+
+ it('warns when the children do not match the tabs prop', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ mockUseCollapsibleTabsContextOrThrow.mockReturnValue(tabsContext());
+
+ CollapsibleTabs.Pager({
+ children: React.createElement(CollapsibleTabs.Tab, { name: 'b' }),
+ });
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('do not match the `tabs` prop')
+ );
+ warn.mockRestore();
+ });
+});
+
+describe('CollapsibleTabs.Tab', () => {
+ it('provides its name as the default scrollId', () => {
+ const child = React.createElement('Child');
+ const element = CollapsibleTabs.Tab({
+ name: 'feed',
+ children: child,
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(View);
+ expect(element.props.collapsable).toBe(false);
+
+ const provider = element.props.children;
+ expect(provider.type).toBe(HeaderMotionScrollIdContext.Provider);
+ expect(provider.props.value).toBe('feed');
+ expect(provider.props.children).toBe(child);
+ });
+});
+
+describe('CollapsibleTabs.Bar', () => {
+ beforeEach(() => {
+ mockUseCollapsibleTabsContextOrThrow.mockReset();
+ });
+
+ it('renders a pressable per tab and navigates on press', () => {
+ const goTo = jest.fn();
+ mockUseCollapsibleTabsContextOrThrow.mockReturnValue({
+ tabs: [
+ { name: 'a', label: 'Page A' },
+ { name: 'b', label: 'Page B' },
+ ],
+ activeTab: 'a',
+ goTo,
+ });
+
+ const element = CollapsibleTabs.Bar({}) as React.ReactElement;
+ expect(element.type).toBe(View);
+
+ const [first, second] = element.props.children;
+ expect(first.type).toBe(Pressable);
+ expect(first.props.accessibilityState).toEqual({ selected: true });
+ expect(second.props.accessibilityState).toEqual({ selected: false });
+
+ second.props.onPress();
+ expect(goTo).toHaveBeenCalledWith('b');
+ });
+});
diff --git a/src/collapsible/__tests__/pagerAdapters.test.tsx b/src/collapsible/__tests__/pagerAdapters.test.tsx
new file mode 100644
index 0000000..8403e2f
--- /dev/null
+++ b/src/collapsible/__tests__/pagerAdapters.test.tsx
@@ -0,0 +1,122 @@
+jest.mock('react', () => {
+ const ReactActual = jest.requireActual('react');
+
+ return {
+ ...ReactActual,
+ useMemo: (factory: () => unknown) => factory(),
+ useCallback: (callback: unknown) => callback,
+ useRef: (initial: unknown) => ({ current: initial }),
+ useEffect: jest.fn(),
+ useState: (initial: unknown) => [
+ typeof initial === 'function' ? initial() : initial,
+ jest.fn(),
+ ],
+ useImperativeHandle: (ref: any, create: () => unknown) => {
+ const value = create();
+ if (typeof ref === 'function') {
+ ref(value);
+ } else if (ref) {
+ ref.current = value;
+ }
+ },
+ };
+});
+
+import React from 'react';
+import { Dimensions, ScrollView } from 'react-native';
+import {
+ DefaultCollapsibleTabsPagerAdapter,
+ createPagerViewAdapter,
+ type CollapsibleTabsPagerController,
+} from '../pagerAdapters';
+
+describe('DefaultCollapsibleTabsPagerAdapter', () => {
+ const windowWidth = Dimensions.get('window').width;
+
+ function render(overrides: Record = {}) {
+ const onIndexChange = jest.fn();
+ const controllerRef: { current: CollapsibleTabsPagerController | null } = {
+ current: null,
+ };
+ const element = DefaultCollapsibleTabsPagerAdapter({
+ initialIndex: 0,
+ onIndexChange,
+ controllerRef,
+ children: [
+ React.createElement('PageA', { key: 'a' }),
+ React.createElement('PageB', { key: 'b' }),
+ ],
+ ...overrides,
+ }) as React.ReactElement;
+
+ return { element, onIndexChange, controllerRef };
+ }
+
+ it('renders a paging horizontal ScrollView with fixed-width pages', () => {
+ const { element } = render();
+
+ expect(element.type).toBe(ScrollView);
+ expect(element.props.horizontal).toBe(true);
+ expect(element.props.pagingEnabled).toBe(true);
+ expect(element.props.showsHorizontalScrollIndicator).toBe(false);
+
+ const pages = element.props.children;
+ expect(pages).toHaveLength(2);
+ expect(pages[0].props.style[1]).toEqual({ width: windowWidth });
+ });
+
+ it('starts at the initial index', () => {
+ const { element } = render({ initialIndex: 1 });
+
+ expect(element.props.contentOffset).toEqual({ x: windowWidth, y: 0 });
+ });
+
+ it('reports page changes from momentum end offsets', () => {
+ const { element, onIndexChange } = render();
+
+ element.props.onMomentumScrollEnd({
+ nativeEvent: { contentOffset: { x: windowWidth * 2 } },
+ });
+
+ expect(onIndexChange).toHaveBeenCalledWith(2);
+ });
+
+ it('exposes a controller that does not crash before the ref attaches', () => {
+ const { controllerRef } = render();
+
+ expect(controllerRef.current).not.toBeNull();
+ expect(() => controllerRef.current!.setIndex(1)).not.toThrow();
+ });
+});
+
+describe('createPagerViewAdapter', () => {
+ function FakePagerView() {
+ return null;
+ }
+
+ it('maps the adapter contract onto pager-view props', () => {
+ const Adapter = createPagerViewAdapter(FakePagerView) as (
+ props: unknown
+ ) => React.ReactElement;
+ const onIndexChange = jest.fn();
+ const controllerRef: { current: CollapsibleTabsPagerController | null } = {
+ current: null,
+ };
+
+ const element = Adapter({
+ initialIndex: 1,
+ onIndexChange,
+ controllerRef,
+ children: React.createElement('Page', { key: 'a' }),
+ }) as React.ReactElement;
+
+ expect(element.type).toBe(FakePagerView);
+ expect(element.props.initialPage).toBe(1);
+
+ element.props.onPageSelected({ nativeEvent: { position: 2 } });
+ expect(onIndexChange).toHaveBeenCalledWith(2);
+
+ expect(controllerRef.current).not.toBeNull();
+ expect(() => controllerRef.current!.setIndex(0)).not.toThrow();
+ });
+});
diff --git a/src/collapsible/__tests__/presets.test.ts b/src/collapsible/__tests__/presets.test.ts
new file mode 100644
index 0000000..56734e4
--- /dev/null
+++ b/src/collapsible/__tests__/presets.test.ts
@@ -0,0 +1,176 @@
+import {
+ CollapsiblePresets,
+ createCollapsiblePreset,
+ mergeCollapsiblePartStyles,
+ resolveCollapsiblePartStyle,
+ resolveCollapsiblePresets,
+} from '../presets';
+import type { CollapsiblePreset, CollapsiblePresetContext } from '../types';
+
+const context = (
+ progress: number,
+ progressThreshold = 100
+): CollapsiblePresetContext => ({ progress, progressThreshold });
+
+describe('CollapsiblePresets', () => {
+ it('collapse counter-translates the wrapper and slides the content up', () => {
+ const preset = CollapsiblePresets.collapse();
+
+ expect(preset(context(0.5))).toEqual({
+ dynamic: { transform: [{ translateY: 50 }] },
+ dynamicContent: { transform: [{ translateY: -50 }] },
+ });
+ expect(preset(context(0))).toEqual({
+ dynamic: { transform: [{ translateY: 0 }] },
+ dynamicContent: { transform: [{ translateY: -0 }] },
+ });
+ });
+
+ it('fade maps progress onto opacity over its range', () => {
+ const preset = CollapsiblePresets.fade();
+
+ expect(preset(context(0)).dynamicContent).toEqual({ opacity: 1 });
+ expect(preset(context(0.4)).dynamicContent).toEqual({ opacity: 0.5 });
+ expect(preset(context(0.8)).dynamicContent).toEqual({ opacity: 0 });
+ expect(preset(context(1)).dynamicContent).toEqual({ opacity: 0 });
+ });
+
+ it('fade respects a custom range', () => {
+ const preset = CollapsiblePresets.fade({ from: 0.2, to: 0.7 });
+
+ expect(preset(context(0.2)).dynamicContent).toEqual({ opacity: 1 });
+ expect(preset(context(0.45)).dynamicContent!.opacity).toBeCloseTo(0.5);
+ expect(preset(context(0.7)).dynamicContent).toEqual({ opacity: 0 });
+ });
+
+ it('parallax lags the content by the configured factor', () => {
+ const preset = CollapsiblePresets.parallax({ factor: 0.25, fade: false });
+
+ expect(preset(context(0.8, 200)).dynamicContent).toEqual({
+ transform: [{ translateY: 40 }],
+ });
+ });
+
+ it('parallax fades by default', () => {
+ const preset = CollapsiblePresets.parallax();
+ const styles = preset(context(0.8)).dynamicContent!;
+
+ expect(styles.transform).toEqual([{ translateY: 40 }]);
+ expect(styles.opacity).toBe(0);
+ });
+
+ it('scale shrinks toward the configured end scale', () => {
+ const preset = CollapsiblePresets.scale({ to: 0.5, fade: false });
+
+ expect(preset(context(1)).dynamicContent).toEqual({
+ transform: [{ scale: 0.5 }],
+ });
+ expect(preset(context(0)).dynamicContent).toEqual({
+ transform: [{ scale: 1 }],
+ });
+ });
+
+ it('none produces no styles', () => {
+ expect(CollapsiblePresets.none()(context(0.5))).toEqual({});
+ });
+});
+
+describe('createCollapsiblePreset', () => {
+ it('returns the preset unchanged', () => {
+ const preset: CollapsiblePreset = () => ({});
+ expect(createCollapsiblePreset(preset)).toBe(preset);
+ });
+});
+
+describe('resolveCollapsiblePresets', () => {
+ it('resolves built-in names with default options', () => {
+ const [preset] = resolveCollapsiblePresets('fade');
+ expect(preset!(context(0.8)).dynamicContent).toEqual({ opacity: 0 });
+ });
+
+ it('keeps preset functions as-is and supports mixed arrays', () => {
+ const custom: CollapsiblePreset = () => ({ header: { opacity: 0.5 } });
+ const resolved = resolveCollapsiblePresets(['collapse', custom]);
+
+ expect(resolved).toHaveLength(2);
+ expect(resolved[1]).toBe(custom);
+ expect(resolved[0]!(context(1)).dynamic).toEqual({
+ transform: [{ translateY: 100 }],
+ });
+ });
+
+ it('throws on unknown preset names', () => {
+ expect(() =>
+ resolveCollapsiblePresets('spin' as unknown as 'collapse')
+ ).toThrow('Unknown collapsible preset "spin"');
+ });
+});
+
+describe('mergeCollapsiblePartStyles', () => {
+ it('concatenates transforms in order', () => {
+ expect(
+ mergeCollapsiblePartStyles([
+ { transform: [{ translateY: 10 }] },
+ undefined,
+ { transform: [{ scale: 0.5 }] },
+ ])
+ ).toEqual({
+ transform: [{ translateY: 10 }, { scale: 0.5 }],
+ });
+ });
+
+ it('multiplies numeric opacities', () => {
+ expect(
+ mergeCollapsiblePartStyles([{ opacity: 0.5 }, { opacity: 0.5 }])
+ ).toEqual({ opacity: 0.25 });
+ });
+
+ it('lets the last style win for other properties', () => {
+ expect(
+ mergeCollapsiblePartStyles([
+ { backgroundColor: 'red', borderTopWidth: 1 },
+ { backgroundColor: 'blue' },
+ ])
+ ).toEqual({ backgroundColor: 'blue', borderTopWidth: 1 });
+ });
+
+ it('omits transform and opacity keys when no style defines them', () => {
+ expect(mergeCollapsiblePartStyles([{ borderTopWidth: 2 }])).toEqual({
+ borderTopWidth: 2,
+ });
+ });
+});
+
+describe('resolveCollapsiblePartStyle', () => {
+ it('merges the intrinsic style with every preset result for the part', () => {
+ const presets = resolveCollapsiblePresets([
+ 'collapse',
+ CollapsiblePresets.fade(),
+ ]);
+
+ expect(
+ resolveCollapsiblePartStyle(
+ 'dynamicContent',
+ { transform: [{ translateX: 5 }] },
+ presets,
+ context(0.4)
+ )
+ ).toEqual({
+ transform: [{ translateX: 5 }, { translateY: -40 }],
+ opacity: 0.5,
+ });
+ });
+
+ it('returns only the intrinsic style when presets skip the part', () => {
+ const presets = resolveCollapsiblePresets('none');
+
+ expect(
+ resolveCollapsiblePartStyle(
+ 'header',
+ { transform: [{ translateY: -40 }] },
+ presets,
+ context(0.4)
+ )
+ ).toEqual({ transform: [{ translateY: -40 }] });
+ });
+});
diff --git a/src/collapsible/__tests__/useCollapsibleHeader.test.ts b/src/collapsible/__tests__/useCollapsibleHeader.test.ts
new file mode 100644
index 0000000..a165780
--- /dev/null
+++ b/src/collapsible/__tests__/useCollapsibleHeader.test.ts
@@ -0,0 +1,128 @@
+const mockUseHeaderMotionContextOrThrow = jest.fn();
+
+jest.mock('react', () => {
+ const ReactActual = jest.requireActual('react');
+
+ return {
+ ...ReactActual,
+ useMemo: (factory: () => unknown) => factory(),
+ useCallback: (callback: unknown) => callback,
+ };
+});
+
+jest.mock('../../context', () => {
+ const actual = jest.requireActual('../../context');
+
+ return {
+ __esModule: true,
+ ...actual,
+ useHeaderMotionContextOrThrow: (...args: any[]) =>
+ mockUseHeaderMotionContextOrThrow(...args),
+ };
+});
+
+import { useCollapsibleHeader } from '../useCollapsibleHeader';
+
+const worklets = require('react-native-worklets');
+
+function createSharedValue(value: T) {
+ return {
+ get: jest.fn(() => value),
+ set: jest.fn(),
+ value,
+ } as any;
+}
+
+describe('useCollapsibleHeader', () => {
+ let scheduleOnUISpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ scheduleOnUISpy = jest
+ .spyOn(worklets, 'scheduleOnUI')
+ .mockImplementation((worklet: any, ...args: any[]) => worklet(...args));
+ });
+
+ afterAll(() => {
+ scheduleOnUISpy.mockRestore();
+ });
+
+ beforeEach(() => {
+ mockUseHeaderMotionContextOrThrow.mockReset();
+ scheduleOnUISpy.mockClear();
+ });
+
+ function createContext({
+ scrollValues = { feed: { min: 20, current: 60 } },
+ activeScrollId,
+ scrollTo = jest.fn(),
+ }: {
+ scrollValues?: Record;
+ activeScrollId?: any;
+ scrollTo?: jest.Mock | null;
+ } = {}) {
+ const ctxValue = {
+ progress: createSharedValue(0.5),
+ progressThreshold: createSharedValue(100),
+ scrollValues: createSharedValue(scrollValues),
+ activeScrollId,
+ scrollToRef: { current: scrollTo },
+ };
+ mockUseHeaderMotionContextOrThrow.mockReturnValue(ctxValue);
+ return { ctxValue, scrollTo };
+ }
+
+ it('collapse scrolls the resolved scrollable to the collapsed offset', () => {
+ const { scrollTo } = createContext();
+
+ useCollapsibleHeader().collapse();
+
+ expect(scrollTo).toHaveBeenCalledWith(120, {
+ isValueDelta: false,
+ animated: true,
+ });
+ });
+
+ it('expand scrolls back to the scrollable minimum', () => {
+ const { scrollTo } = createContext();
+
+ useCollapsibleHeader().expand({ animated: false });
+
+ expect(scrollTo).toHaveBeenCalledWith(20, {
+ isValueDelta: false,
+ animated: false,
+ });
+ });
+
+ it('targets the active scrollable in multi-scroll setups', () => {
+ const { scrollTo } = createContext({
+ scrollValues: {
+ a: { min: 0, current: 0 },
+ b: { min: 5, current: 40 },
+ },
+ activeScrollId: createSharedValue('b'),
+ });
+
+ useCollapsibleHeader().collapse();
+
+ expect(scrollTo).toHaveBeenCalledWith(105, {
+ isValueDelta: false,
+ animated: true,
+ });
+ });
+
+ it('does nothing when no scrollable is connected yet', () => {
+ createContext({ scrollTo: null });
+
+ expect(() => useCollapsibleHeader().collapse()).not.toThrow();
+ expect(scheduleOnUISpy).not.toHaveBeenCalled();
+ });
+
+ it('exposes the motion progress values', () => {
+ const { ctxValue } = createContext();
+
+ const result = useCollapsibleHeader();
+
+ expect(result.progress).toBe(ctxValue.progress);
+ expect(result.progressThreshold).toBe(ctxValue.progressThreshold);
+ });
+});
diff --git a/src/collapsible/context.ts b/src/collapsible/context.ts
new file mode 100644
index 0000000..a9098f7
--- /dev/null
+++ b/src/collapsible/context.ts
@@ -0,0 +1,53 @@
+import { createContext, useContext, type RefObject } from 'react';
+import type { CollapsibleTabsPagerController } from './pagerAdapters';
+import type { CollapsiblePreset } from './types';
+
+export const CollapsiblePresetsContext = createContext<
+ readonly CollapsiblePreset[] | null
+>(null);
+
+export function useCollapsiblePresetsOrThrow(
+ componentName: string
+): readonly CollapsiblePreset[] {
+ const presets = useContext(CollapsiblePresetsContext);
+ if (!presets) {
+ throw new Error(
+ `${componentName} must be used within or . ` +
+ 'If you are rendering inside a navigation header, use so the collapsible context crosses the tree boundary.'
+ );
+ }
+
+ return presets;
+}
+
+/** A tab entry after normalization — `label` falls back to `name`. */
+export interface CollapsibleTabDescriptor {
+ name: string;
+ label: string;
+}
+
+export interface CollapsibleTabsContextValue {
+ tabs: readonly CollapsibleTabDescriptor[];
+ activeTab: string;
+ goTo: (name: string) => void;
+ initialIndex: number;
+ controllerRef: RefObject;
+ onPagerIndexChange: (index: number) => void;
+}
+
+export const CollapsibleTabsContext =
+ createContext(null);
+
+export function useCollapsibleTabsContextOrThrow(
+ componentName: string
+): CollapsibleTabsContextValue {
+ const ctxValue = useContext(CollapsibleTabsContext);
+ if (!ctxValue) {
+ throw new Error(
+ `${componentName} must be used within . ` +
+ 'If you are rendering inside a navigation header, use so the tabs context crosses the tree boundary.'
+ );
+ }
+
+ return ctxValue;
+}
diff --git a/src/collapsible/index.ts b/src/collapsible/index.ts
new file mode 100644
index 0000000..10e3930
--- /dev/null
+++ b/src/collapsible/index.ts
@@ -0,0 +1,49 @@
+export {
+ Collapsible,
+ type CollapsibleProps,
+ type CollapsibleHeaderProps,
+ type CollapsiblePinnedProps,
+ type CollapsibleDynamicProps,
+ type CollapsibleNavigationHeaderProps,
+} from './Collapsible';
+export {
+ CollapsibleTabs,
+ type CollapsibleTabsProps,
+ type CollapsibleTabsTabInput,
+ type CollapsibleTabsPagerProps,
+ type CollapsibleTabProps,
+ type CollapsibleTabsBarProps,
+} from './CollapsibleTabs';
+export {
+ CollapsiblePresets,
+ createCollapsiblePreset,
+ type CollapsibleFadeOptions,
+ type CollapsibleParallaxOptions,
+ type CollapsibleScaleOptions,
+} from './presets';
+export {
+ DefaultCollapsibleTabsPagerAdapter,
+ createPagerViewAdapter,
+ type CollapsiblePagerViewLikeProps,
+ type CollapsibleTabsPagerAdapter,
+ type CollapsibleTabsPagerAdapterProps,
+ type CollapsibleTabsPagerController,
+} from './pagerAdapters';
+export {
+ useCollapsibleHeader,
+ type CollapsibleScrollOptions,
+ type UseCollapsibleHeaderResult,
+} from './useCollapsibleHeader';
+export {
+ useCollapsibleTabs,
+ type UseCollapsibleTabsResult,
+} from './useCollapsibleTabs';
+export type { CollapsibleTabDescriptor } from './context';
+export type {
+ CollapsibleHeaderState,
+ CollapsiblePreset,
+ CollapsiblePresetContext,
+ CollapsiblePresetInput,
+ CollapsiblePresetName,
+ CollapsiblePresetPartStyles,
+} from './types';
diff --git a/src/collapsible/pagerAdapters.tsx b/src/collapsible/pagerAdapters.tsx
new file mode 100644
index 0000000..7676b61
--- /dev/null
+++ b/src/collapsible/pagerAdapters.tsx
@@ -0,0 +1,220 @@
+import {
+ Children,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+ type ComponentRef,
+ type ComponentType,
+ type ReactNode,
+ type Ref,
+} from 'react';
+import {
+ ScrollView,
+ StyleSheet,
+ View,
+ useWindowDimensions,
+ type LayoutChangeEvent,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+ type StyleProp,
+ type ViewStyle,
+} from 'react-native';
+
+/**
+ * Imperative surface a pager adapter must expose so `CollapsibleTabs` can
+ * change pages programmatically (for example from a tab-bar press).
+ */
+export interface CollapsibleTabsPagerController {
+ /** Animates the pager to the page at `index`. */
+ setIndex: (index: number) => void;
+}
+
+/**
+ * Props every pager adapter receives from `CollapsibleTabs.Pager`.
+ *
+ * An adapter renders `children` (one element per tab, in order), reports
+ * user-driven page changes through `onIndexChange`, and assigns a
+ * {@link CollapsibleTabsPagerController} to `controllerRef` for programmatic
+ * page changes.
+ */
+export interface CollapsibleTabsPagerAdapterProps {
+ /** Index of the page that should be shown initially. */
+ initialIndex: number;
+ /** Reports that the user moved the pager to the page at `index`. */
+ onIndexChange: (index: number) => void;
+ /** Receives the adapter's imperative controller. */
+ controllerRef: Ref;
+ /** Style for the pager container. Defaults to `flex: 1` when omitted. */
+ style?: StyleProp;
+ /** The tab pages, one element per tab, in tab order. */
+ children: ReactNode;
+}
+
+/**
+ * A pager engine for `CollapsibleTabs.Pager`.
+ *
+ * The library ships a dependency-free default (a paging horizontal
+ * `ScrollView`) and `createPagerViewAdapter()` for `react-native-pager-view`.
+ * Provide your own adapter to plug in any other pager implementation.
+ */
+export type CollapsibleTabsPagerAdapter =
+ ComponentType;
+
+const styles = StyleSheet.create({
+ pager: {
+ flex: 1,
+ },
+ page: {
+ height: '100%',
+ },
+});
+
+/**
+ * Default pager engine: a paging horizontal `ScrollView`.
+ *
+ * It has no dependencies beyond React Native, supports swiping between pages
+ * and programmatic page changes, and keeps every page mounted so scroll
+ * positions are preserved. For native pager behavior, pass
+ * `createPagerViewAdapter(PagerView)` to `CollapsibleTabs.Pager` instead.
+ */
+export function DefaultCollapsibleTabsPagerAdapter({
+ initialIndex,
+ onIndexChange,
+ controllerRef,
+ style,
+ children,
+}: CollapsibleTabsPagerAdapterProps) {
+ const { width: windowWidth } = useWindowDimensions();
+ const [pageWidth, setPageWidth] = useState(windowWidth);
+ const pageWidthRef = useRef(pageWidth);
+ const scrollRef = useRef>(null);
+
+ useEffect(() => {
+ pageWidthRef.current = pageWidth;
+ }, [pageWidth]);
+
+ useImperativeHandle(
+ controllerRef,
+ () => ({
+ setIndex: (index: number) => {
+ scrollRef.current?.scrollTo({
+ x: index * pageWidthRef.current,
+ animated: true,
+ });
+ },
+ }),
+ []
+ );
+
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
+ const width = e.nativeEvent.layout.width;
+ if (width > 0) {
+ setPageWidth((previous) => (previous === width ? previous : width));
+ }
+ }, []);
+
+ const handleMomentumScrollEnd = useCallback(
+ (e: NativeSyntheticEvent) => {
+ const width = pageWidthRef.current;
+ if (width <= 0) {
+ return;
+ }
+
+ onIndexChange(Math.round(e.nativeEvent.contentOffset.x / width));
+ },
+ [onIndexChange]
+ );
+
+ return (
+
+ {Children.map(children, (child) => (
+ {child}
+ ))}
+
+ );
+}
+
+/**
+ * Structural subset of `react-native-pager-view`'s props that the adapter
+ * relies on. Typed loosely on purpose so the library does not depend on
+ * `react-native-pager-view`'s types.
+ */
+export interface CollapsiblePagerViewLikeProps {
+ ref?: Ref;
+ style?: StyleProp;
+ initialPage?: number;
+ onPageSelected?: (event: {
+ nativeEvent: {
+ position: number;
+ };
+ }) => void;
+ children?: ReactNode;
+}
+
+/**
+ * Creates a `CollapsibleTabs.Pager` adapter backed by
+ * `react-native-pager-view`.
+ *
+ * The component is passed in by the caller so the library never imports
+ * `react-native-pager-view` itself — it stays a regular dependency of *your*
+ * app, not of `react-native-header-motion`.
+ *
+ * @example
+ * ```tsx
+ * import PagerView from 'react-native-pager-view';
+ * import { createPagerViewAdapter } from 'react-native-header-motion';
+ *
+ * const pagerAdapter = createPagerViewAdapter(PagerView);
+ *
+ *
+ * ```
+ */
+export function createPagerViewAdapter(
+ PagerViewComponent: ComponentType
+): CollapsibleTabsPagerAdapter {
+ function PagerViewAdapter({
+ initialIndex,
+ onIndexChange,
+ controllerRef,
+ style,
+ children,
+ }: CollapsibleTabsPagerAdapterProps) {
+ const pagerRef = useRef<{ setPage?: (index: number) => void } | null>(null);
+
+ useImperativeHandle(
+ controllerRef,
+ () => ({
+ setIndex: (index: number) => {
+ pagerRef.current?.setPage?.(index);
+ },
+ }),
+ []
+ );
+
+ return (
+ onIndexChange(e.nativeEvent.position)}
+ >
+ {children}
+
+ );
+ }
+
+ PagerViewAdapter.displayName = 'CollapsibleTabs.PagerViewAdapter';
+ return PagerViewAdapter;
+}
diff --git a/src/collapsible/presets.ts b/src/collapsible/presets.ts
new file mode 100644
index 0000000..1839d6b
--- /dev/null
+++ b/src/collapsible/presets.ts
@@ -0,0 +1,280 @@
+import type { ViewStyle } from 'react-native';
+import type {
+ CollapsiblePreset,
+ CollapsiblePresetContext,
+ CollapsiblePresetInput,
+ CollapsiblePresetName,
+ CollapsiblePresetPartStyles,
+} from './types';
+
+function clamp01(value: number): number {
+ 'worklet';
+ return Math.min(1, Math.max(0, value));
+}
+
+function rangeProgress(progress: number, from: number, to: number): number {
+ 'worklet';
+ if (to <= from) {
+ return progress >= to ? 1 : 0;
+ }
+
+ return clamp01((progress - from) / (to - from));
+}
+
+/**
+ * Identity helper for authoring custom collapsible presets with full typing.
+ *
+ * The preset function runs on the UI thread inside the collapsible parts'
+ * animated styles, so it **must be marked with the `'worklet'` directive**.
+ *
+ * @example
+ * ```ts
+ * const myPreset = createCollapsiblePreset(({ progress, progressThreshold }) => {
+ * 'worklet';
+ * return {
+ * dynamicContent: { opacity: 1 - progress },
+ * header: { borderBottomWidth: progress },
+ * };
+ * });
+ * ```
+ */
+export function createCollapsiblePreset(
+ preset: CollapsiblePreset
+): CollapsiblePreset {
+ return preset;
+}
+
+export interface CollapsibleFadeOptions {
+ /**
+ * Progress value at which the fade starts.
+ *
+ * @default 0
+ */
+ from?: number;
+ /**
+ * Progress value at which the content is fully transparent.
+ *
+ * @default 0.8
+ */
+ to?: number;
+}
+
+export interface CollapsibleParallaxOptions {
+ /**
+ * Fraction of the collapse distance the content lags behind by. `0` moves
+ * the content 1:1 with the header, `1` keeps it visually still.
+ *
+ * @default 0.5
+ */
+ factor?: number;
+ /**
+ * Also fades the content out while it collapses.
+ *
+ * @default true
+ */
+ fade?: boolean;
+}
+
+export interface CollapsibleScaleOptions {
+ /**
+ * Scale of the content in the fully collapsed state.
+ *
+ * @default 0.9
+ */
+ to?: number;
+ /**
+ * Also fades the content out while it collapses.
+ *
+ * @default true
+ */
+ fade?: boolean;
+}
+
+const collapse = (): CollapsiblePreset => {
+ return (context: CollapsiblePresetContext) => {
+ 'worklet';
+ const offset = context.progress * context.progressThreshold;
+
+ return {
+ dynamic: { transform: [{ translateY: offset }] },
+ dynamicContent: { transform: [{ translateY: -offset }] },
+ };
+ };
+};
+
+const fade = ({
+ from = 0,
+ to = 0.8,
+}: CollapsibleFadeOptions = {}): CollapsiblePreset => {
+ return (context: CollapsiblePresetContext) => {
+ 'worklet';
+
+ return {
+ dynamicContent: {
+ opacity: 1 - rangeProgress(context.progress, from, to),
+ },
+ };
+ };
+};
+
+const parallax = ({
+ factor = 0.5,
+ fade: withFade = true,
+}: CollapsibleParallaxOptions = {}): CollapsiblePreset => {
+ return (context: CollapsiblePresetContext) => {
+ 'worklet';
+ const transform = [
+ { translateY: context.progress * context.progressThreshold * factor },
+ ];
+ const dynamicContent: ViewStyle = withFade
+ ? { transform, opacity: 1 - rangeProgress(context.progress, 0, 0.8) }
+ : { transform };
+
+ return { dynamicContent };
+ };
+};
+
+const scale = ({
+ to = 0.9,
+ fade: withFade = true,
+}: CollapsibleScaleOptions = {}): CollapsiblePreset => {
+ return (context: CollapsiblePresetContext) => {
+ 'worklet';
+ const transform = [{ scale: 1 - (1 - to) * context.progress }];
+ const dynamicContent: ViewStyle = withFade
+ ? { transform, opacity: 1 - rangeProgress(context.progress, 0, 0.8) }
+ : { transform };
+
+ return { dynamicContent };
+ };
+};
+
+const none = (): CollapsiblePreset => {
+ return () => {
+ 'worklet';
+ return {};
+ };
+};
+
+/**
+ * Built-in collapsible presets as configurable factories.
+ *
+ * Every factory returns a `CollapsiblePreset` you can pass to the `preset`
+ * prop, alone or in an array to combine effects:
+ *
+ * ```tsx
+ *
+ *
+ * ```
+ *
+ * The string shorthands (`preset="parallax"`) resolve to these factories with
+ * their default options.
+ *
+ * - `collapse` — content slides up and is clipped under the pinned sections
+ * (the classic iOS large-title behavior).
+ * - `fade` — content fades out in place while the header collapses.
+ * - `parallax` — content lags behind the collapse and fades out.
+ * - `scale` — content shrinks and fades out.
+ * - `none` — no content effect; the header still slides up.
+ */
+export const CollapsiblePresets = {
+ collapse,
+ fade,
+ parallax,
+ scale,
+ none,
+};
+
+const BUILT_IN_PRESETS: Record CollapsiblePreset> =
+ CollapsiblePresets;
+
+/**
+ * Normalizes a `preset` prop value into a flat list of preset functions,
+ * resolving built-in names to their default-configured factories.
+ */
+export function resolveCollapsiblePresets(
+ input: CollapsiblePresetInput
+): readonly CollapsiblePreset[] {
+ const items = Array.isArray(input) ? input : [input];
+
+ return (items as readonly (CollapsiblePresetName | CollapsiblePreset)[]).map(
+ (item) => {
+ if (typeof item !== 'string') {
+ return item;
+ }
+
+ const factory = BUILT_IN_PRESETS[item];
+ if (!factory) {
+ throw new Error(
+ `[react-native-header-motion] Unknown collapsible preset "${item}". ` +
+ `Available presets: ${Object.keys(BUILT_IN_PRESETS).join(', ')}.`
+ );
+ }
+
+ return factory();
+ }
+ );
+}
+
+/**
+ * Merges part styles coming from multiple presets into one style object.
+ *
+ * `transform` arrays are concatenated in order, numeric `opacity` values are
+ * multiplied, and every other property is taken from the last style that
+ * defines it.
+ */
+export function mergeCollapsiblePartStyles(
+ styles: readonly (ViewStyle | undefined)[]
+): ViewStyle {
+ 'worklet';
+ const result: Record = {};
+ let transform: unknown[] | null = null;
+ let opacity: number | null = null;
+
+ for (let i = 0; i < styles.length; i++) {
+ const style = styles[i] as Record | undefined;
+ if (!style) {
+ continue;
+ }
+
+ for (const key in style) {
+ const value = style[key];
+ if (key === 'transform' && Array.isArray(value)) {
+ transform = transform ? transform.concat(value) : value.slice();
+ } else if (key === 'opacity' && typeof value === 'number') {
+ opacity = opacity === null ? value : opacity * value;
+ } else {
+ result[key] = value;
+ }
+ }
+ }
+
+ if (transform) {
+ result.transform = transform;
+ }
+ if (opacity !== null) {
+ result.opacity = opacity;
+ }
+
+ return result as ViewStyle;
+}
+
+/**
+ * Evaluates every preset for one header part and merges the results on top of
+ * the part's intrinsic style.
+ */
+export function resolveCollapsiblePartStyle(
+ part: keyof CollapsiblePresetPartStyles,
+ intrinsic: ViewStyle | undefined,
+ presets: readonly CollapsiblePreset[],
+ context: CollapsiblePresetContext
+): ViewStyle {
+ 'worklet';
+ const collected: (ViewStyle | undefined)[] = [intrinsic];
+
+ for (let i = 0; i < presets.length; i++) {
+ collected.push(presets[i]!(context)[part]);
+ }
+
+ return mergeCollapsiblePartStyles(collected);
+}
diff --git a/src/collapsible/types.ts b/src/collapsible/types.ts
new file mode 100644
index 0000000..84e220d
--- /dev/null
+++ b/src/collapsible/types.ts
@@ -0,0 +1,85 @@
+import type { ViewStyle } from 'react-native';
+
+/**
+ * Snapshot of the motion state a collapsible preset is evaluated against.
+ *
+ * The object is intentionally open-ended: future versions may extend it with
+ * additional state (for example pull-to-refresh progress once the headless
+ * refresh control lands) without breaking existing presets. Presets should
+ * read the fields they need and ignore the rest.
+ */
+export interface CollapsiblePresetContext {
+ /**
+ * Current header-motion progress, usually in the `0..1` range where `0` is
+ * fully expanded and `1` is fully collapsed.
+ */
+ progress: number;
+ /**
+ * Pixel distance that maps `progress` from `0` to `1`. This is the measured
+ * height of `Collapsible.Dynamic` unless overridden via `progressThreshold`.
+ */
+ progressThreshold: number;
+}
+
+/**
+ * Styles a preset produces for each part of a collapsible header.
+ *
+ * All keys are optional — a preset only describes the parts it wants to
+ * animate. When multiple presets are combined, their part styles are merged:
+ * `transform` arrays are concatenated, numeric `opacity` values are
+ * multiplied, and any other property is taken from the last preset that
+ * defines it.
+ */
+export interface CollapsiblePresetPartStyles {
+ /**
+ * Style for the header container (`Collapsible.Header`), merged on top of
+ * its intrinsic slide-up transform.
+ */
+ header?: ViewStyle;
+ /**
+ * Style for pinned sections (`Collapsible.Pinned`), merged on top of their
+ * intrinsic counter-translate transform.
+ */
+ pinned?: ViewStyle;
+ /**
+ * Style for the measured collapsing wrapper (`Collapsible.Dynamic`). Use
+ * transforms only — this element's layout defines the collapse distance.
+ */
+ dynamic?: ViewStyle;
+ /**
+ * Style for the content inside `Collapsible.Dynamic`. This is where most
+ * visual effects (fade, parallax, scale) belong.
+ */
+ dynamicContent?: ViewStyle;
+}
+
+/**
+ * A collapsible preset: a worklet mapping the current motion state to styles
+ * for the collapsible header parts.
+ *
+ * Custom presets **must be marked with the `'worklet'` directive** — they run
+ * on the UI thread inside the parts' animated styles.
+ */
+export type CollapsiblePreset = (
+ context: CollapsiblePresetContext
+) => CollapsiblePresetPartStyles;
+
+/** Names of the built-in collapsible presets. */
+export type CollapsiblePresetName =
+ | 'collapse'
+ | 'fade'
+ | 'parallax'
+ | 'scale'
+ | 'none';
+
+/**
+ * Anything accepted by the `preset` prop: a built-in preset name, a configured
+ * or custom preset function, or an array of those to combine.
+ */
+export type CollapsiblePresetInput =
+ | CollapsiblePresetName
+ | CollapsiblePreset
+ | readonly (CollapsiblePresetName | CollapsiblePreset)[];
+
+/** Terminal states reported by `Collapsible`'s `onStateChange`. */
+export type CollapsibleHeaderState = 'expanded' | 'collapsed';
diff --git a/src/collapsible/useCollapsibleHeader.ts b/src/collapsible/useCollapsibleHeader.ts
new file mode 100644
index 0000000..04e3623
--- /dev/null
+++ b/src/collapsible/useCollapsibleHeader.ts
@@ -0,0 +1,87 @@
+import { useCallback, useMemo } from 'react';
+import { scheduleOnUI } from 'react-native-worklets';
+import { useHeaderMotionContextOrThrow } from '../context';
+import type { MotionProgress } from '../types';
+import { resolveScrollIdForProgress } from '../utils';
+
+export interface CollapsibleScrollOptions {
+ /**
+ * Animates the scroll to the target state.
+ *
+ * @default true
+ */
+ animated?: boolean;
+}
+
+export interface UseCollapsibleHeaderResult extends MotionProgress {
+ /** Scrolls the active scrollable so the header fully collapses. */
+ collapse: (options?: CollapsibleScrollOptions) => void;
+ /** Scrolls the active scrollable so the header fully expands. */
+ expand: (options?: CollapsibleScrollOptions) => void;
+}
+
+/**
+ * Motion state and imperative controls for a collapsible header.
+ *
+ * Works anywhere inside a `Collapsible`, `CollapsibleTabs`, or plain
+ * `HeaderMotion` tree. `collapse()` and `expand()` scroll the currently
+ * active scrollable to the corresponding terminal state.
+ *
+ * @example
+ * ```tsx
+ * const { progress, collapse, expand } = useCollapsibleHeader();
+ * ```
+ */
+export function useCollapsibleHeader(): UseCollapsibleHeaderResult {
+ const {
+ progress,
+ progressThreshold,
+ scrollValues,
+ activeScrollId,
+ scrollToRef,
+ } = useHeaderMotionContextOrThrow(
+ 'useCollapsibleHeader must be used within , , or .'
+ );
+
+ const scrollToProgress = useCallback(
+ (target: 0 | 1, options?: CollapsibleScrollOptions) => {
+ const scrollToActive = scrollToRef.current;
+ if (!scrollToActive) {
+ return;
+ }
+
+ const animated = options?.animated ?? true;
+
+ scheduleOnUI(() => {
+ 'worklet';
+ const values = scrollValues.get();
+ const id = resolveScrollIdForProgress(values, activeScrollId?.get());
+ const scrollValue = values[id];
+ if (!scrollValue) {
+ return;
+ }
+
+ const y =
+ target === 1
+ ? scrollValue.min + progressThreshold.get()
+ : scrollValue.min;
+ scrollToActive(y, { isValueDelta: false, animated });
+ });
+ },
+ [scrollToRef, scrollValues, activeScrollId, progressThreshold]
+ );
+
+ const collapse = useCallback(
+ (options?: CollapsibleScrollOptions) => scrollToProgress(1, options),
+ [scrollToProgress]
+ );
+ const expand = useCallback(
+ (options?: CollapsibleScrollOptions) => scrollToProgress(0, options),
+ [scrollToProgress]
+ );
+
+ return useMemo(
+ () => ({ progress, progressThreshold, collapse, expand }),
+ [progress, progressThreshold, collapse, expand]
+ );
+}
diff --git a/src/collapsible/useCollapsibleTabs.ts b/src/collapsible/useCollapsibleTabs.ts
new file mode 100644
index 0000000..890972c
--- /dev/null
+++ b/src/collapsible/useCollapsibleTabs.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+import {
+ useCollapsibleTabsContextOrThrow,
+ type CollapsibleTabDescriptor,
+} from './context';
+
+export interface UseCollapsibleTabsResult {
+ /** The normalized tabs, in pager order. */
+ tabs: readonly CollapsibleTabDescriptor[];
+ /** Name of the currently active tab. */
+ activeTab: string;
+ /** Moves the pager to the given tab and makes it active. */
+ goTo: (name: string) => void;
+}
+
+/**
+ * Tab state and navigation for a `CollapsibleTabs` tree.
+ *
+ * Use it to build a custom tab bar (or any other tab-aware UI) in place of
+ * `CollapsibleTabs.Bar`.
+ *
+ * @example
+ * ```tsx
+ * function MyTabBar() {
+ * const { tabs, activeTab, goTo } = useCollapsibleTabs();
+ * }
+ * ```
+ */
+export function useCollapsibleTabs(): UseCollapsibleTabsResult {
+ const { tabs, activeTab, goTo } =
+ useCollapsibleTabsContextOrThrow('useCollapsibleTabs');
+
+ return useMemo(() => ({ tabs, activeTab, goTo }), [tabs, activeTab, goTo]);
+}
diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx
index f14ffda..4aaa75f 100644
--- a/src/components/HeaderMotion.tsx
+++ b/src/components/HeaderMotion.tsx
@@ -22,34 +22,9 @@ import {
DEFAULT_PROGRESS_THRESHOLD,
DEFAULT_SCROLL_ID,
getInitialScrollValue,
+ resolveScrollIdForProgress,
} from '../utils';
-const resolveScrollIdForProgress = (
- scrollValues: ScrollValues,
- activeScrollIdValue: string | undefined
-) => {
- 'worklet';
-
- if (activeScrollIdValue) {
- return activeScrollIdValue;
- }
-
- let onlyNonDefaultId: string | null = null;
- for (const key in scrollValues) {
- if (key === DEFAULT_SCROLL_ID) {
- continue;
- }
-
- if (onlyNonDefaultId !== null) {
- return DEFAULT_SCROLL_ID;
- }
-
- onlyNonDefaultId = key;
- }
-
- return onlyNonDefaultId ?? DEFAULT_SCROLL_ID;
-};
-
export interface HeaderMotionProps {
/**
* Distance that maps the active scrollable from `progress = 0`
diff --git a/src/context.ts b/src/context.ts
index 0adb73b..6f66c73 100644
--- a/src/context.ts
+++ b/src/context.ts
@@ -4,6 +4,19 @@ import type { HeaderMotionBridgeValue } from './types';
export const HeaderMotionContext =
createContext(null);
+/**
+ * Provides a default `scrollId` to every header-motion scrollable in its
+ * subtree.
+ *
+ * An explicit `scrollId` prop always wins over this context. Container
+ * components that own a scroll region (for example `CollapsibleTabs.Tab`)
+ * provide it so pre-wired and custom scrollables participate in multi-scroll
+ * setups without manual `scrollId` wiring.
+ */
+export const HeaderMotionScrollIdContext = createContext(
+ undefined
+);
+
export function useHeaderMotionContextOrThrow(errorMessage: string) {
const ctxValue = useContext(HeaderMotionContext);
if (!ctxValue) {
diff --git a/src/hooks/useScrollManager.ts b/src/hooks/useScrollManager.ts
index cbc618c..1f8ffc7 100644
--- a/src/hooks/useScrollManager.ts
+++ b/src/hooks/useScrollManager.ts
@@ -16,7 +16,7 @@ import {
type ScrollHandler,
} from 'react-native-reanimated';
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
-import { HeaderMotionContext } from '../context';
+import { HeaderMotionContext, HeaderMotionScrollIdContext } from '../context';
import type { ScrollManagerConfig, ScrollHandlerContext } from '../types';
import type { LayoutChangeEvent } from 'react-native';
import {
@@ -336,7 +336,9 @@ export interface UseScrollManagerOptions
* below the measured header
*
* In multi-scroll setups, pass a unique `scrollId` for each scrollable.
- * In single-scroll setups, you usually do not need one.
+ * In single-scroll setups, you usually do not need one. Container components
+ * that own a scroll region (for example `CollapsibleTabs.Tab`) can provide a
+ * default id through context; an explicit `scrollId` always wins over it.
*
* If you need the same fallback behavior but prefer render-prop composition
* over a hook, use `HeaderMotion.ScrollManager`.
@@ -375,7 +377,8 @@ export function useScrollManager(
options?: UseScrollManagerOptions
): ScrollManagerConfig {
const { originalHeaderHeight } = useScrollManagerContext();
- const id = scrollId ?? DEFAULT_SCROLL_ID;
+ const contextScrollId = useContext(HeaderMotionScrollIdContext);
+ const id = scrollId ?? contextScrollId ?? DEFAULT_SCROLL_ID;
const ensureScrollableContentMinHeight =
options?.ensureScrollableContentMinHeight ?? false;
diff --git a/src/index.ts b/src/index.ts
index b0075fa..b38519f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -105,6 +105,7 @@ const HeaderMotion: HeaderMotionCompound = Object.assign(
export default HeaderMotion;
export * from './hooks';
+export * from './collapsible';
export { ScrollablePresets } from './utils/presets';
export type * from './types';
export { createHeaderMotionScrollable };
diff --git a/src/utils/values.ts b/src/utils/values.ts
index 540ccaa..77932c0 100644
--- a/src/utils/values.ts
+++ b/src/utils/values.ts
@@ -29,6 +29,37 @@ export function ensureScrollValueRegistered(
return scrollValues.get();
}
+/**
+ * Resolves which scrollable currently drives the shared `progress` value:
+ * the active scroll id when one is set, otherwise the only registered
+ * non-default id, falling back to the default id.
+ */
+export function resolveScrollIdForProgress(
+ scrollValues: ScrollValues,
+ activeScrollIdValue: string | undefined
+): string {
+ 'worklet';
+
+ if (activeScrollIdValue) {
+ return activeScrollIdValue;
+ }
+
+ let onlyNonDefaultId: string | null = null;
+ for (const key in scrollValues) {
+ if (key === DEFAULT_SCROLL_ID) {
+ continue;
+ }
+
+ if (onlyNonDefaultId !== null) {
+ return DEFAULT_SCROLL_ID;
+ }
+
+ onlyNonDefaultId = key;
+ }
+
+ return onlyNonDefaultId ?? DEFAULT_SCROLL_ID;
+}
+
export function warnIfMissingActiveScrollId(
scrollValues: ScrollValues,
id: string,