Skip to content

Changelog ​

0.9.0 — 2026-09-11 ​

  • New: onScrollOffsetChange — the active list's live scroll offset ({ index, offset }, dp from its content top; negative on an over-drag), per frame while it moves and once whenever the active page changes. Read from the same native callback that moves the bands, and emitted only while a handler is set. Same contract as onHeaderOffsetChange: pass a Reanimated useEvent worklet ('topScrollOffsetChange') and nothing per frame reaches the JS thread. Closes the "list scroll position is not readable" limitation — for parallax deeper in the page, a scroll-to-top pill, a reading-progress bar.
  • Fix (iOS, regression in 0.8.1): a tab opened after scrolling another one could stay blank until touched. 0.8.1 made the band heights exact (fractional) points, and UIKit snaps a scroll view's offset to the pixel grid when it is set — so the neighbour-page sync that hides a page until it has reached the header offset compared 223.333… against a readback a hair short and never passed. Every "reached" check now carries a half-point tolerance, and a header derived from a list parked at the collapse point snaps onto it — otherwise it counted as "not fully collapsed", which is the test that lets a neighbouring page keep a deeper scroll, so swiping away and back reset the list you had scrolled.

0.8.1 — 2026-09-10 ​

  • Fix: a hairline of header showed above the pinned tab bar once collapsed (visible from 0.8.0, where header and tab bar stack inside one scroll view). The shell rounded the measured band heights to whole dp before handing them to native, so a 223.33 pt header collapsed by 223 and left 0.33 pt of itself in view. headerHeight / tabBarHeight are now exact (Float), pages pad by the exact sum, and Android converts to pixels by rounding, the way Yoga snaps layout to the pixel grid.

0.8.0 — 2026-09-09 ​

  • Fix: buttons in the header and tab bar dropped presses whenever the bands were away from where JS laid them out — every hard press on a 3D Touch iPhone (force changes stream touch-moves), and any tap whose finger rolled a little, on both platforms. Pressable decides "still on me?" from measure(), which on Fabric reads the shadow tree, and the shadow tree never knew the shell had moved the bands natively. The bands now live in a real React Native ScrollView (tabs-bands) that the shell drives by content offset; RN writes that offset into the shadow tree on every scroll, so the measured rect and the real one agree at any collapse offset. Internal to the shell — no API change — but a custom CollapsibleTabsShell consumer that relied on the tabs-header / tabs-tabbar children being direct children of the host should note they now sit inside tabs-bands.
  • New: the bundled TabBar tracks the finger. Its underline and label colour now interpolate with the pager's swipe position instead of jumping on settle. CollapsibleTabView feeds it onPageScroll automatically when the default tab bar is in use (a plain-function onPageScroll of your own is chained; a Reanimated worklet keeps the old settle-only animation, since one native event has one handler). TabBar also takes a positionAnimated.Value directly for custom setups.
  • Fix (iOS): switching tabs while a page was held open by pull-to-refresh (or mid-bounce) left the bands a refresh band too low over the new tab — pull was only ever read from the active page's scroll callback, and the new page never scrolled to correct it. It is now re-read on activation and when a refresh ends on a page that is no longer active.
  • Example: a Bench screen (500-row list + a JS-thread load switch, also reachable through collapsibletabs://bench?load=1) and scripts/bench-android.sh, which drives it over adb and prints dumpsys gfxinfo frame stats. First numbers, from a Galaxy M53 at 120 Hz, in docs/benchmarks.md: 0 % janky frames with the JS thread 60 % busy.
  • New: pull-to-refresh is configurable — refreshThreshold (dp, default 70), refreshIndicatorOffset (dp the spinner rests at while refreshing, default 60), refreshTintColor, refreshBackgroundColor (the disc behind the spinner; Android's native look, now drawn on iOS too), refreshIndicatorSize ('default' | 'large') and refreshIndicatorHidden (hide the native spinner and draw your own from onHeaderOffsetChange's pull on iOS; on Android the spinner is made invisible, the gesture still fires). Backed by SwipeRefreshLayout's own setters on Android.
  • Docs: README brought up to date — the "not exposed per frame" and "no min-header" limitations were stale since onHeaderOffsetChange / headerMinHeight, the FAQ still recommended tabs-inside-the-header, and it now answers the questions that actually come up (pinning inside the header, sticky section headers under the bands, Animated.event, expand() in direction mode, empty tabs, header-strip gestures, Jest, supported RN versions).
  • Fix (iOS): the refresh spinner no longer appears on a momentum bounce. A fling to the top overshoots into UIKit's rubber-band, the offset goes negative for a few frames, and the engine read that exactly like a finger pull — spinner in, spinner out. The bands still follow the bounce (the list must stay attached to the tab bar), but the spinner now arms only for an over-drag a finger started — the list's own tracking or a header-band drag — the way UIRefreshControl does, and fades naturally once released.

0.7.0 — 2026-09-03 ​

  • New: onHeaderOffsetChange — the bands' live offset ({ offset, collapsibleHeight, pull }, dp) per frame while they move, on both platforms. Same contract as onPageScroll: pass a Reanimated useEvent worklet and it is read on the UI thread with no per-frame JS; a plain function works but costs a JS call per frame. Armed only while a handler is set, emitted only on change, and the current value is emitted once when a handler is attached late. Unlocks headers that react to their own collapse — avatar shrink, title fade, cover parallax — and list section headers that pin below the bands. pull is the iOS over-drag (0 on Android, where SwipeRefreshLayout owns it).
  • New: headerMinHeight — the bottom strip of the header that stays pinned above the tab bar instead of scrolling away (a search bar, a filter row). The bands then travel headerHeight - headerMinHeight; the tab bar necessarily stays visible too, so pinTabBar={false} is ignored while it is > 0. allowFullCollapse slack and the collapse threshold follow the shorter travel.
  • Fix: when a Reanimated useEvent handler is passed but react-native-reanimated cannot be loaded, the shell now warns in development and leaves the handler (and its native event) off, instead of passing the raw handler object to the native prop — which React rejected with "Expected onHeaderOffsetChange listener to be a function". Seen in the example, where Metro resolved the library's optional require from the repo root, never reaching example/node_modules (fixed there with resolver.nodeModulesPaths).
  • Example: Reanimated added; the header's avatar shrinks and the bio fades with the collapse, and a "keep chips" toggle pins the chip row via headerMinHeight.

0.6.0 — 2026-09-03 ​

  • New: an imperative ref (CollapsibleTabsRef) on CollapsibleTabView and CollapsibleTabsShell — scrollToTop({ index?, animated? }), setIndex(index, { animated? }), collapse({ animated? }), expand({ animated? }). Fabric commands on both platforms. Every method goes THROUGH the collapse engine: the header is derived from the active list's position, so they move the list and let the header follow instead of moving the header alone (which would leave a gap under the tab bar). In direction mode collapse lands the bands exactly on the collapse point once the list can hold it, and expand animates the header by itself, which that mode allows. setIndex emits onIndexChange exactly like a swipe, so a controlled index stays the source of truth.
  • Fix: the pager settling on the page JS already selected is no longer echoed as onIndexChange. A tab press sets the index, the pager animates there, and native reported the settle — indistinguishable from the user tapping the active tab again, which is the gesture consumers use for "scroll to top". Only a genuinely different index (a swipe) is reported.
  • Breaking-ish: React Native peer floor raised from 0.76 to 0.80. The spec now imports codegenNativeComponent / codegenNativeCommands and the CodegenTypes from the react-native root — the deep react-native/Libraries/... paths are deprecated as of 0.83 and warned on every app launch. The root exports exist since 0.80.

0.5.2 — 2026-09-02 ​

  • Fix (iOS): a vertical drag on the tab strip no longer slides it sideways. 0.5.1 gave the header band a horizontal-list hit-test and suspended that list for the duration of a vertical drag; the tab-bar band never got the same treatment and fell through to a bare direction check. Since a tab strip is a horizontal scroll view in every tab bar worth the name — this package's own TabBar included — that was the default configuration, not an edge case: the band pan and the strip's own pan recognise simultaneously, so one drag scrolled the page AND dragged the tabs. Both bands now share one rule. (Android was never affected: intercepting the touch there sends ACTION_CANCEL to the descendant, which ends its gesture for free.)
  • Fix (iOS): a suspended strip could be left permanently unscrollable. Only one strip was tracked, so a second suspension dropped the first, and a shell recycled mid-gesture never saw the pan end that would have restored it. A strip the consumer disabled with scrollEnabled={false} is now also left alone rather than being re-enabled on gesture end.
  • Fix (iOS): a strip this shell had suspended was invisible to the hit-test that decides who owns the next gesture, so a drag arriving before the restore landed was handed to the band instead of the strip.
  • Fix (iOS): the band-drag arbitration no longer decides from velocity — an instantaneous reading that one jitter frame flips, which is how a wiggled drag ended with the strip AND the page both moving. Direction is now read from the gesture's accumulated translation, and the band pan commits an intent ONCE per gesture: nothing moves before the commit, so an ambiguous first frame cannot walk the page around, and a majority-horizontal drag on the header is swallowed whole instead of driving the page with its vertical drift (worst over a strip whose content had not loaded yet — nothing to detect, nothing to scroll, every drift frame moved the page).
  • Fix (iOS): a touch landing on a band now stops the page's momentum, exactly as a touch on the list itself would. Starting a new drag on a header strip before the previous fling had ended left that fling scrolling the page under the new gesture — which read as both axes moving at once.
  • Fix (iOS): the one hole hit-testing cannot close. A touch between a strip's items is answered by RN's scroll component with its own WRAPPER — the scroll view is never in the hit view's ancestor chain — and a strip whose content has not loaded fails the contentSize check besides, so the band pan drove the page while the undetected strip's own pan scrolled it sideways. The shell now also listens to UIKit's simultaneous-recognition query: any scroll pan co-recognising with a band pan, on a scroll view living inside a band, IS a strip under this finger — captured there and suspended the moment the band pan commits to driving the page, with no hit-testing involved.
  • Fix (iOS): a shell that mounted straight onto a non-zero tab (deep link, restored state) never activated it. Fabric applies props before layout metrics, so the selection arrived at zero width and could not scroll the pager; the first layout then pinned the right page into view while the engine still considered page 0 active — every scroll of the visible page was ignored and the header never moved on that tab until the user swiped away and back. The selection is now activated in the first layout with real width (the counterpart of Android's pinPagerToSelection, which is why Android never had this).
  • Fix (iOS): a recycled shell now actually unsubscribes from the scroll views it listened to — emptying its own bookkeeping table never removed it as a delegate, so it kept hearing from views that had been recycled into other screens; the drag-start handler (the one callback with no ownership guard) then cancelled THAT screen's in-flight touches. The handler is also ownership-guarded now, which covers the same leak for a list unmounted while its shell lives on.
  • Fix: the JS visited set is pruned when the routes shrink, matching the native reveal set. Stale indices past the new end stayed "visited" forever, so if the routes later grew again those pages mounted eagerly — lazy silently off for them.
  • Fix: resolveHost is sticky — once the native host has been wrapped for a Reanimated onPageScroll handler, the wrapped component keeps being used even if the handler is later removed. Swapping the element type back remounted the entire native shell: every page, every scroll position.
  • Fix (Android): detach now also clears the reveal-stuck fallback and recycles the retained touch event; reattach re-arms the fallback if any page still owes a sync, so a page can never stay invisible across a detach/attach cycle.
  • Chore: yarn lint actually works now (ESLint flat config + typescript-eslint + react-hooks; it had no config and no dependency and had never run), and it found real issues: the lazy-mount visited set is now derived during render instead of in effects, so a freshly visited page can never paint one frame behind the committed state, and the default TabBar's Animated.Value moved off a render-time ref read. CI runs typecheck + lint on GitHub Actions.
  • Chore: react-native-reanimated is declared as an optional peer (peerDependenciesMeta) instead of an undeclared require; README documents the Jest transformIgnorePatterns requirement for the untranspiled-TypeScript entry point.
  • Chore: an example/ app (RN 0.83, New Architecture) consuming the library straight from the repo root — Metro watch + autolinking overrides, no copying — with one screen that exercises the whole surface: header chip strip, long/short/ScrollView tabs, pull-to-refresh, threshold chrome swap, and live toggles for collapseMode / pinTabBar / allowFullCollapse.

0.5.1 — 2026-09-01 ​

  • Fix (iOS): a horizontal list inside the header — a date picker, a chip row — no longer fights the header's own drag. The band pan used to begin for ANY touch on the header and recognises simultaneously with that list, so one drag scrolled the list AND drove the page. It now hit-tests for a horizontally scrollable view under the finger, as Android already did, and arbitrates by direction: sideways belongs to the list, vertical to the page, so the list is not a dead zone for scrolling either.
  • Fix (iOS): a vertical drag starting on such a list no longer slides it sideways. Its scrolling is suspended for the duration of that gesture — isDirectionalLockEnabled is not enough, since it only arbitrates on a scroll view that can scroll both ways, and these strips are horizontal-only.

0.5.0 — 2026-09-01 ​

  • New: pinTabBar (default true). Pass false and the tab-bar band collapses as part of the header — the whole band, tabs included, scrolls away together. Prefer it over rendering a tab strip INSIDE renderHeader: pages clear the band's height either way, so the shell has somewhere to put it back without landing on content. With an empty band there is no clearance at all, which collapseMode="direction" exposes immediately (the bands return on any up-scroll).
  • Fix: switching tabs in direction mode now aligns the incoming page to the header offset instead of keeping a deeper scroll. A page left scrolled past the offset shows its content BEHIND the bands — returning to a tab you had scrolled put its first card under the header.
  • Fix (iOS): the extra bottom inset is now tracked per SCROLL VIEW rather than per page. Fabric recycles scroll views — one instance serves different pages over time — so a page-keyed record drifts from the inset actually on the view: it kept an inset nobody believed they had applied, leaving the page hundreds of points of range nobody accounted for and letting its content run up under the header. (Android already keyed this by the view, which is why it was unaffected.)
  • Fix (iOS): read adjustedContentInset, not contentInset. UIKit clamps the content offset against the adjusted value, and RN's safe-area handling lives in the difference — so every scroll figure was out by the safe area, always in the direction of granting extra range.
  • Fix (iOS): a page's list can be REPLACED without the page remounting (a grid/list toggle re-keys it). Nothing re-ran discovery, so the new scroll view was never registered: its scrolls never reached the collapse engine and it never got its range. It is now re-resolved on touch when the cached view has gone. The contentSize observer is also invalidated with it — kept alive, it blocked a replacement from ever being created.
  • Fix (iOS): the end-of-list bounce no longer reveals the header. A rubber-band springback is a decreasing offset, indistinguishable in direction mode from the user scrolling up; the position that drives the header is now held inside the page's real range.
  • Breaking-ish: allowFullCollapse now defaults to true. A tab that cannot scroll is a tab whose header cannot collapse, and whose blank area swallows every drag — which reads as a broken screen rather than a deliberate default. Pass allowFullCollapse={false} for the previous behaviour (the header eases back to whatever a short tab can hold).
  • Fix (Android): with allowFullCollapse off, the slack pass still re-laid a page's content view out, and could pin it to a stale recorded height — costing that page its scroll range entirely. Worst on a screen whose list remounts (a grid/list toggle), where the replacement content view was measured once at discovery and never refreshed. It now returns immediately for any page it was never asked to extend, and treats the current height as natural whenever nothing of ours is applied.
  • Fix (iOS): guard against re-entering the slack pass. It mutates contentInset, UIKit fires scrollViewDidScroll on inset changes, and the clamp heal there calls straight back into it — a cycle with nothing to stop it if a pass does not converge. The computed slack is also ceilinged at one header plus one viewport so a disagreement settles instead of compounding.

0.4.0 — 2026-09-01 ​

  • New: onPageScroll — the pager's live swipe position (position + a 0..1 offset), so a custom tab bar can interpolate its indicator with the finger instead of snapping when the swipe settles. It is the one per-frame event here and is emitted only while a handler is attached, so nothing else pays for it. Intended for a Reanimated useEvent worklet (UI thread, no per-frame JS); Reanimated stays an OPTIONAL peer and is only required, or even imported, if you pass a worklet handler. RN Animated.event with useNativeDriver is documented as unsupported: on Fabric such events only reach the animated module through a deprecated back-channel React Native special-cases for its own ScrollView and has marked for removal.
  • Fix: a page's list is locked to one axis per drag (isDirectionalLockEnabled). Every real horizontal swipe carries some vertical drift, and the list scrolled along with the page turn. It surfaced now because two earlier fixes opened the vertical axis where it used to be inert: allowFullCollapse gives short pages real range, and blank-area touches are routed to the scroll view.
  • New: pages mount on peek. Native announces a page the instant any sliver of it is on screen (onPageRevealed, emitted once per page, so no per-frame JS), and the shell mounts it then — so a lazy tab mounts and is aligned to the header while it is still sliding in, instead of after the swipe settles. Previously a freshly opened tab painted at its own scroll position first and jumped into place a frame later.
  • Fix: a page knocked out of sync by a layout pass is now corrected inside that same pass. When a tab's content re-lays out, Fabric resets the content view's height and ReactScrollView.onLayout re-issues a scrollTo that clamps against it; reacting to the resulting scroll event is one frame late, and one frame late is a flicker.
  • Fix: a page that still owes a sync is hidden until it lands (Android as well as iOS). Such a page is not where the header says it is, and with mount-on-peek it mounts while already sliding into view, so it would otherwise paint a header too low before snapping into place. A page in that state is lazy-mounted and blank anyway. A stuck sync reveals the page regardless after a short fallback, so a tab can never stay invisible.
  • Fix (Android): pressing a tab while the header was collapsed sprang the header back open; swiping to the same tab did not. ViewPager2 dispatches onPageSelected at the start of a programmatic scroll, so the incoming page became active before anything had aligned it, and the header was then reconciled against a page still at its own scroll position. The incoming page is now aligned to the current header offset before it becomes active, so a pressed tab opens exactly as collapsed as the one you left.
  • Fix (Android): allowFullCollapse gave a page its extra range as bottom padding on the ScrollView, which getMaxScrollY() honours — but not the check that decides whether a drag may start. ScrollView.onInterceptTouchEvent bails on scrollY == 0 && !canScrollVertically(1), and that measures the content child's bottom against the viewport, where bottom padding does not count. A short tab was therefore untouchable until something scrolled it programmatically, and locked again the moment it returned to the top. The range is now added to the content view's height instead, which every path agrees on. (Fabric also re-applies a view's own padding on layout, so the padding was being wiped from under us as well.)
  • Fix (iOS): with allowFullCollapse, dragging the blank area below a short tab's items did nothing. RCTScrollViewComponentView hit-tests only the subviews of its content container and returns its own wrapper for anything else — and that wrapper is the scroll view's parent, so such a touch never reaches the scroll view's pan recogniser. (Normally invisible: a list that short has nothing to scroll.) Those touches are now handed to the scroll view itself.
  • Fix: a page's scroll being clamped by a layout pass is no longer mistaken for the user scrolling. When a tab's content mounts, Fabric re-lays it out at its natural height and ReactScrollView.onLayout re-issues a scrollTo that clamps against it — the header followed that to 0 and sprang open on the first visit to a tab. A scroll the page could not currently hold is now treated as a clamp: the page is re-extended and the offset restored.
  • Fix: allowFullCollapse under-provisioned every page whose content was shorter than the viewport. The page's natural scroll range was clamped at zero before the shortfall was subtracted, so a page needing headerHeight + |shortfall| got only headerHeight. A tab that nearly filled the screen stopped just short of a full collapse, and a tab with one or two rows could not scroll at all — while a fully empty tab happened to work, because a fixed-height empty state puts its range near zero, where the old arithmetic was accidentally correct.
  • Fix: with allowFullCollapse, a page was aligned to the header offset before it had been given its extra scroll range, so the alignment clamped to whatever range the page happened to have. A short tab then kept the last slice of the header on screen, and the give-up eased the header open a beat later. Pages now get their range before anything scrolls them.
  • Fix: with allowFullCollapse, switching to a tab that had never been opened sprang the header open — the page was still mounting when the 400ms give-up fired, and an unmounted page reads as "too short to hold the offset". A page that has not caught up is now treated as still mounting: Android holds the header and aligns the page as soon as its content lands, and iOS retries the alignment before conceding.
  • Fix (iOS): switching tabs briefly showed the incoming page's content one header too low before it snapped into place — a visible flicker, on swipe most of all, because a lazy page mounts while it is already sliding into view and paints before anything can align it. A page that still owes a sync is by definition not where the header says it is, so it stays hidden from the moment the sync is owed until it lands (or is conceded). Such a page is lazy-mounted and blank at that point anyway, so nothing is lost.
  • Fix (iOS): switching to a tab whose list had not mounted left the header collapsed over a page still at its top — a gap the height of the header under the tab bar, with the page scrolling independently of it. A sync left pending for a page with no scroll view scheduled no retry at all (the contentSize observer that would have retried is only registered once the scroll view resolves), so nothing ever completed it. Such a sync is now always armed.

0.3.0 — 2026-08-31 ​

  • New: allowFullCollapse?: boolean (default false). Tabs whose content is too short to scroll — an empty state, a single row — can now collapse the header anyway. Native hands such a page exactly the scroll range it lacks (bottom contentInset on iOS, bottom padding on Android, which ReactScrollView.getMaxScrollY() counts), so the header collapses on every tab alike instead of popping back open when you switch to a short one. Pages with enough content are untouched, the slack is re-measured whenever the content, the viewport or the header height changes, and nothing moves until the user scrolls.

0.2.1 — 2026-08-31 ​

  • Fix: pull-to-refresh no longer arms when no onRefresh handler is provided (refreshEnabled is derived from it). Previously the spinner started on pull and nothing could ever clear it.
  • Fix: when the header (or tab bar) re-measures after first render — data arriving, images/fonts settling — the collapse offset is re-derived from the active list's real scroll position instead of merely clamped. Previously the pages re-padded to the new height while the bands kept a stale offset, showing a phantom gap under the tab bar until the next scroll.

0.2.0 — 2026-08-31 ​

  • New: collapseMode?: 'classic' | 'direction' (default 'classic'). 'direction' makes the header offset follow the scroll delta — any upward scroll reveals the header, any downward scroll hides it (home-feed feel), pinned open at the very top. Tab switches keep the header where it is and only concede when the incoming page cannot hold the current offset.
  • Fix (Android): a horizontal list inside the header (date pickers, chip rows) could not be swiped — the header's horizontal-drag swallow now hit-tests for a horizontally scrollable view under the finger and leaves the gesture to it.

0.1.1 — 2026-08-29 ​

  • Fix: a swipe or drag that ended over a Pressable fired the press. The shell now cancels React's in-flight touch whenever it takes over the gesture — page-list scroll start, pager (tab) drag start, and header/tab strip drags — on iOS (RCTSurfaceTouchHandler) and Android (NativeGestureUtil.notifyNativeGestureStarted).
  • Fix: horizontal drags on the header are now claimed (still inert) so the button under the finger doesn't fire on release.
  • Fix (iOS): on a fresh launch the header didn't follow a plain list scroll until the header was touched once — the active page's scroll view is now discovered eagerly (with a short retry for lazily mounted lists).

0.1.0 — 2026-08-29 ​

Initial release.

  • Native collapsible-tabs shell for React Native (Fabric / New Architecture).
  • iOS (UIKit paging scroll view) and Android (ViewPager2) implementations; header, tab bar and pages stay React components, re-parented natively.
  • Header and list translate from the same native scroll callback — no frame lag between them.
  • CollapsibleTabView (tab-view-like API), CollapsibleTabsShell, createTabList (+ TabScrollView, TabFlatList), TabBar, useCollapsibleTabs.
  • Drag the header to scroll; horizontal header swipes inert by design.
  • Container-level pull-to-refresh; onCollapsedChange threshold event; lazy pages with offset sync.

Released under the MIT License.