Skip to content
Native Animation
Collapsible Profile Tabs animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add collapsible-profile-tabs

Collapsible Profile Tabs

Profile tabs with a collapsing header and remembered scroll positions, with configurable tabs, header content, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

Profile tabs with a collapsing header and remembered scroll positions, with configurable tabs, header content, palette, and fonts.

How it works

  1. 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.
  2. The profile is the measured ListHeaderComponent. The next cell contains the opaque tab band, pinned by the list's native stickyHeaderIndices. There is no outer vertical scroll container or inverse scroll transform.
  3. On the UI runtime, the active list updates a global header collapse C = clamp(scrollY, 0, D). D is the measured profile height. The indicator, border, and short shadow respond independently of React rendering.
  4. Each list stores a content offset O, excluding the profile. Switching tabs preserves current C, then restores D + O when pinned, or C when partially expanded. Dormant content offsets remain saved during partial expansion and resume when the header reaches its pinned boundary.
  5. Header resizing recalculates D and restores content coordinates. Native maintainVisibleContentPosition helps retain the visible item during data edits; keep IDs stable. Removing a tab discards its stored position, while reordering preserves it by tab ID.
  6. 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-gradient

Use 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

PropTypeDefaultPurpose
tabsreadonly ProfileTab<T>[]Required{ id, label, data, disabled? } per tab.
keyExtractor(item: T) => stringRequiredStable item identity within each tab.
renderItem({ item, index, tabId }) => ReactNodeRequiredVirtualized cell content.
renderHeader({ collapsed }) => ReactNodeRequiredMeasured profile; receives discrete state only.
renderAvatar() => ReactNode—Optional leading header slot. The demo composes its overlapping avatar inside renderHeader.
activeTabIdstring—Controlled selection.
defaultActiveTabIdstringFirst enabledInitial uncontrolled selection.
onTabChange(id, { reason }) => void—Reasons: press, keyboard, imperative, removed, reset.
onCollapseChange(collapsed: boolean) => void—Fully pinned boundary transitions.
topInsetnumber0Host-owned space above lists, applied once.
initialOffsetsReadonly<Record<string, number>>{}Initial content coordinates; read at tab mount.
onOffsetCommit(tabId, offset) => void—Offsets at drag/momentum completion or explicit action completion.
labelsPartial<ProfileTabsLabels>Neutral EnglishEmpty, loading, retry, return action, tab-position formatter.
loadingByTabReadonly<Record<string, boolean>>{}Loading state replaces rows.
errorByTabReadonly<Record<string, string | undefined>>{}Host-provided error text replaces rows.
onRetry(tabId) => void—Enables the retry button.
disabledbooleanfalseBlocks scroll, controls, and imperative actions except reset.
colorScheme'light' | 'dark' | 'auto''auto'Paper, Night, or system appearance.
palettePartial<Palette>Theme rolesAll 16 roles are exposed through PAPER and NIGHT.
fontFamilystringSystemUtility body font.
titleFontFamilystringfontFamilyTabs and action labels.
monoFontFamilystringSystemReserved 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.
styleStyleProp<ViewStyle>—Root override, applied last.
testIDstring'collapsible-profile-tabs'Root and stable tab/list/action suffixes.
accessibilityLabelstring'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.