About this animation
Profile tabs with a collapsing header and remembered scroll positions, with configurable tabs, header content, palette, and fonts.
How it works
- Each enabled tab owns one
Animated.FlatList. Lists stay mounted, but only the selected pane accepts input or appears in the accessibility tree. Rows are virtualized and keyed by item ID. - The profile is the measured
ListHeaderComponent. The next cell contains the opaque tab band, pinned by the list's nativestickyHeaderIndices. There is no outer vertical scroll container or inverse scroll transform. - On the UI runtime, the active list updates a global header collapse
C = clamp(scrollY, 0, D).Dis the measured profile height. The indicator, border, and short shadow respond independently of React rendering. - Each list stores a content offset
O, excluding the profile. Switching tabs preserves current C, then restoresD + Owhen pinned, orCwhen partially expanded. Dormant content offsets remain saved during partial expansion and resume when the header reaches its pinned boundary. - Header resizing recalculates
Dand restores content coordinates. NativemaintainVisibleContentPositionhelps retain the visible item during data edits; keep IDs stable. Removing a tab discards its stored position, while reordering preserves it by tab ID. - Drag and momentum completions notify JavaScript through
scheduleOnRN. Programmatic returns run for a target 300 ms, stop on a new drag, and finish immediately with reduced motion.
Install
Copy this folder without demo/ into your project. In an Expo SDK 58 project:
npx expo install react-native-reanimated react-native-worklets expo-linear-gradientUse the Expo Babel preset with its Worklets integration. No Gesture Handler provider, Skia setup, network images, or safe-area provider is required by the component. The showcase demo uses Skia for one Lucide brand glyph; that dependency is not part of the delivered component.
Usage
import { useRef } from 'react';
import { Text, View } from 'react-native';
import {
CollapsibleProfileTabs,
type CollapsibleProfileTabsHandle,
} from './collapsible-profile-tabs';
const tabs = [
{ id: 'notes', label: 'Notes', data: [{ id: 'first', body: 'A fresh start.' }] },
{ id: 'saved', label: 'Saved', data: [] },
];
export default function Profile() {
const profile = useRef<CollapsibleProfileTabsHandle>(null);
return (
<CollapsibleProfileTabs
ref={profile}
tabs={tabs}
keyExtractor={(item) => item.id}
renderHeader={() => (
<View style={{ padding: 16 }}><Text>My profile</Text></View>
)}
renderItem={({ item }) => (
<View style={{ padding: 16 }}><Text>{item.body}</Text></View>
)}
/>
);
}Personality and integration knobs:
<CollapsibleProfileTabs
tabs={tabs}
keyExtractor={(item) => item.id}
renderHeader={renderProfile}
renderItem={renderNote}
activeTabId={selectedTab}
onTabChange={(id) => setSelectedTab(id)}
initialOffsets={{ notes: 400, saved: 120 }}
onOffsetCommit={(id, offset) => persistPosition(id, offset)}
colorScheme="dark"
palette={{ accent: '#BCCAFF', focus: '#BCCAFF' }}
motion={{ durationScale: 0.8, glowOpacity: 0.1 }}
labels={{
empty: 'Nothing saved yet.',
loading: 'Loading notes…',
retry: 'Try again',
backToTop: 'Back to profile',
tabPosition: (label, position, total) => `${label}, tab ${position} of ${total}`,
}}
style={{ flex: 0, width: 300, height: 400 }}
/>renderProfile, renderNote, persistence, and controlled selection belong to your host. Style header and item content with your own palette and injected fonts. Slots remain in normal layout flow and may grow with Dynamic Type. Give the host a bounded height; the component measures its own viewport and caps its content column at 640 points.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
tabs | readonly ProfileTab<T>[] | Required | { id, label, data, disabled? } per tab. |
keyExtractor | (item: T) => string | Required | Stable item identity within each tab. |
renderItem | ({ item, index, tabId }) => ReactNode | Required | Virtualized cell content. |
renderHeader | ({ collapsed }) => ReactNode | Required | Measured profile; receives discrete state only. |
renderAvatar | () => ReactNode | — | Optional leading header slot. The demo composes its overlapping avatar inside renderHeader. |
activeTabId | string | — | Controlled selection. |
defaultActiveTabId | string | First enabled | Initial uncontrolled selection. |
onTabChange | (id, { reason }) => void | — | Reasons: press, keyboard, imperative, removed, reset. |
onCollapseChange | (collapsed: boolean) => void | — | Fully pinned boundary transitions. |
topInset | number | 0 | Host-owned space above lists, applied once. |
initialOffsets | Readonly<Record<string, number>> | {} | Initial content coordinates; read at tab mount. |
onOffsetCommit | (tabId, offset) => void | — | Offsets at drag/momentum completion or explicit action completion. |
labels | Partial<ProfileTabsLabels> | Neutral English | Empty, loading, retry, return action, tab-position formatter. |
loadingByTab | Readonly<Record<string, boolean>> | {} | Loading state replaces rows. |
errorByTab | Readonly<Record<string, string | undefined>> | {} | Host-provided error text replaces rows. |
onRetry | (tabId) => void | — | Enables the retry button. |
disabled | boolean | false | Blocks scroll, controls, and imperative actions except reset. |
colorScheme | 'light' | 'dark' | 'auto' | 'auto' | Paper, Night, or system appearance. |
palette | Partial<Palette> | Theme roles | All 16 roles are exposed through PAPER and NIGHT. |
fontFamily | string | System | Utility body font. |
titleFontFamily | string | fontFamily | Tabs and action labels. |
monoFontFamily | string | System | Reserved for buyer composition; the core renders no numeric text. |
motion | { durationScale?, glowOpacity? } | { 1, 0.1 } | Scale clamped to 0.5–2; halo to 0–0.3. |
style | StyleProp<ViewStyle> | — | Root override, applied last. |
testID | string | 'collapsible-profile-tabs' | Root and stable tab/list/action suffixes. |
accessibilityLabel | string | 'Profile' | Root description. |
Imperative handle and offset coordinates
selectTab(id)requests selection. Controlled props remain authoritative; the callback fires once for the request.scrollTo(tabId, offset, { animated? })uses content coordinates, never raw native scroll coordinates. Zero means the first row below pinned tabs. On the active tab, the header pins before showing this content offset. Inactive tabs store the request without changing global collapse.scrollToTop()expands the active profile and clears that tab's content offset. Other tabs keep their stored content positions.reset()clears all stored offsets, expands the profile, and requests the initial enabled tab. It also works when disabled.
Unknown/disabled IDs are ignored. Empty and duplicate tab IDs are ignored after the first valid occurrence. Empty/duplicate item keys keep the first valid item. An invalid selected ID falls back to the first enabled tab. Removing the active tab emits one removed callback for that fallback; an all-disabled or empty collection shows the empty label. NaN, Infinity, and negative offsets/insets resolve to zero. Offsets clamp to the available content range. Keep stateful item data outside recycled cells, keyed by item ID. Loading/error replacements have their own short scroll range and may clamp old offsets.
Compatibility
Designed for Expo SDK 58, React 19, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Expo LinearGradient 58.0.3, using the New Architecture. Supports iOS, Android, and web, including Expo Go's bundled modules. The host owns safe areas and top insets. Horizontal paging gestures are deliberately outside this component's API; horizontal gestures inside buyer content remain available.
Compilation checks establish dependency and type compatibility. They do not establish device frame rate, native touch behavior, or screen-reader behavior; those require runtime measurement in the target app.
Accessibility
Tabs expose selected/disabled states and formatted positions. Inactive panes cannot receive pointer events, keyboard focus, or screen-reader navigation. Tabs scroll horizontally to reveal focused labels without truncation. Web users can use Tab, Enter/Space, Home/End, and logical left/right arrows. Every action has a 48-point minimum target and a visible focus border. RTL uses logical row layout and reversed spatial arrow navigation.
useReducedMotion() supplies the initial preference; a cleaned-up reduceMotionChanged listener follows live changes. Reduced motion makes indicator changes and scroll requests immediate, while retaining all navigation and content. The utility labels are replaceable. Buyer header and cell slots remain responsible for their own semantics and theme contrast.
Why this one
A collapsing title demonstrates one scroll position. This pattern coordinates a measured profile with several independent content positions, so switching lists preserves the current header state and returns to the reader's previous place. The Orrin demo uses an original initials avatar, neutral cover, indigo accent, and typographic project studies. Raster assets: N/A. Brand glyph: Lucide aperture (ISC); demo fonts: Geist and Geist Mono (OFL). Reference screenshots are private measurement material and are not shipped with this component.





