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 asonHeaderOffsetChange: pass a ReanimateduseEventworklet ('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/tabBarHeightare 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.
Pressabledecides "still on me?" frommeasure(), 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 NativeScrollView(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 customCollapsibleTabsShellconsumer that relied on thetabs-header/tabs-tabbarchildren being direct children of the host should note they now sit insidetabs-bands. - New: the bundled
TabBartracks the finger. Its underline and label colour now interpolate with the pager's swipe position instead of jumping on settle.CollapsibleTabViewfeeds itonPageScrollautomatically when the default tab bar is in use (a plain-functiononPageScrollof your own is chained; a Reanimated worklet keeps the old settle-only animation, since one native event has one handler).TabBaralso takes apositionAnimated.Valuedirectly 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 —
pullwas 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) andscripts/bench-android.sh, which drives it over adb and printsdumpsys gfxinfoframe stats. First numbers, from a Galaxy M53 at 120 Hz, indocs/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') andrefreshIndicatorHidden(hide the native spinner and draw your own fromonHeaderOffsetChange'spullon iOS; on Android the spinner is made invisible, the gesture still fires). Backed bySwipeRefreshLayout'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
UIRefreshControldoes, 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 asonPageScroll: pass a ReanimateduseEventworklet 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.pullis the iOS over-drag (0 on Android, whereSwipeRefreshLayoutowns 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 travelheaderHeight - headerMinHeight; the tab bar necessarily stays visible too, sopinTabBar={false}is ignored while it is > 0.allowFullCollapseslack and the collapse threshold follow the shorter travel. - Fix: when a Reanimated
useEventhandler is passed butreact-native-reanimatedcannot 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 "ExpectedonHeaderOffsetChangelistener to be a function". Seen in the example, where Metro resolved the library's optional require from the repo root, never reachingexample/node_modules(fixed there withresolver.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) onCollapsibleTabViewandCollapsibleTabsShell—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). Indirectionmodecollapselands the bands exactly on the collapse point once the list can hold it, andexpandanimates the header by itself, which that mode allows.setIndexemitsonIndexChangeexactly 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/codegenNativeCommandsand theCodegenTypesfrom thereact-nativeroot — the deepreact-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
TabBarincluded — 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 sendsACTION_CANCELto 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
visitedset 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 —lazysilently off for them. - Fix:
resolveHostis sticky — once the native host has been wrapped for a ReanimatedonPageScrollhandler, 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 lintactually 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-mountvisitedset 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 defaultTabBar'sAnimated.Valuemoved off a render-time ref read. CI runs typecheck + lint on GitHub Actions. - Chore:
react-native-reanimatedis declared as an optional peer (peerDependenciesMeta) instead of an undeclaredrequire; README documents the JesttransformIgnorePatternsrequirement 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 forcollapseMode/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 —
isDirectionalLockEnabledis 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(defaulttrue). Passfalseand 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 INSIDErenderHeader: 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, whichcollapseMode="direction"exposes immediately (the bands return on any up-scroll). - Fix: switching tabs in
directionmode 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, notcontentInset. 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
directionmode from the user scrolling up; the position that drives the header is now held inside the page's real range. - Breaking-ish:
allowFullCollapsenow defaults totrue. 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. PassallowFullCollapse={false}for the previous behaviour (the header eases back to whatever a short tab can hold). - Fix (Android): with
allowFullCollapseoff, 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 firesscrollViewDidScrollon 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..1offset), 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 ReanimateduseEventworklet (UI thread, no per-frame JS); Reanimated stays an OPTIONAL peer and is only required, or even imported, if you pass a worklet handler. RNAnimated.eventwithuseNativeDriveris 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:allowFullCollapsegives 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.onLayoutre-issues ascrollTothat 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
onPageSelectedat 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):
allowFullCollapsegave a page its extra range as bottom padding on the ScrollView, whichgetMaxScrollY()honours — but not the check that decides whether a drag may start.ScrollView.onInterceptTouchEventbails onscrollY == 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.RCTScrollViewComponentViewhit-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.onLayoutre-issues ascrollTothat 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:
allowFullCollapseunder-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 needingheaderHeight + |shortfall|got onlyheaderHeight. 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(defaultfalse). 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 (bottomcontentInseton iOS, bottom padding on Android, whichReactScrollView.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
onRefreshhandler is provided (refreshEnabledis 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;
onCollapsedChangethreshold event; lazy pages with offset sync.