<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ram Suthar</title>
    <description>The latest articles on DEV Community by Ram Suthar (@ramssuthar).</description>
    <link>https://dev.to/ramssuthar</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4114952%2F73770326-d4d1-413d-aded-9a8afd6fdf3a.jpg</url>
      <title>DEV Community: Ram Suthar</title>
      <link>https://dev.to/ramssuthar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ramssuthar"/>
    <language>en</language>
    <item>
      <title>I moved the collapsible-tabs header out of JavaScript, and the gap went away</title>
      <dc:creator>Ram Suthar</dc:creator>
      <pubDate>Tue, 08 Sep 2026 05:35:50 +0000</pubDate>
      <link>https://dev.to/ramssuthar/i-moved-the-collapsible-tabs-header-out-of-javascript-and-the-gap-went-away-5221</link>
      <guid>https://dev.to/ramssuthar/i-moved-the-collapsible-tabs-header-out-of-javascript-and-the-gap-went-away-5221</guid>
      <description>&lt;p&gt;&lt;em&gt;Why every JS collapsible-tab library lags by a frame on a fast fling, and what it took to fix it natively for React Native.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have built an Instagram- or Twitter-style profile screen in React Native, you know the layout: a header that scrolls away, a tab bar that pins under it, and a swipeable pager of lists below. You also probably know the bug: fling a list hard and, for a frame or two, a gap opens between the tab bar and the content. It is worst on Android and on iOS whenever the JS thread is busy.&lt;/p&gt;

&lt;p&gt;I hit this on the profile screen of the app I work on, and after fixing it a couple of times in JS I decided to stop fixing it and remove the cause instead. The result is &lt;a href="https://github.com/ramssutharr/react-native-collapsible-tabs-native" rel="noopener noreferrer"&gt;react-native-collapsible-tabs-native&lt;/a&gt;, a small library where the collapse is owned by UIKit and ViewPager2 rather than by JavaScript or a worklet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap exists
&lt;/h2&gt;

&lt;p&gt;Every JS implementation I have used, including the ones on Reanimated, works the same way. The list content is moved by the native scroll view. The header is moved by an animation callback fed by that scroll's &lt;em&gt;event&lt;/em&gt;. Those are two update paths, and the second one is always at least one frame behind the first.&lt;/p&gt;

&lt;p&gt;Reanimated narrows the gap by running the callback on the UI thread, but it cannot close it. The scroll view has already moved its content by the time the event exists. On a slow frame the header is visibly late, and on Android the event itself is throttled.&lt;/p&gt;

&lt;p&gt;The only way to make the header and the list move in the same frame is to move the header from the same native callback that moved the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the library does
&lt;/h2&gt;

&lt;p&gt;The React side is ordinary components. You give it a header, a tab bar and one page per tab. Fabric mounts them as children of one native view, and the native side re-parents them by &lt;code&gt;nativeID&lt;/code&gt; into slots: a header band and a tab-bar band drawn above a horizontal pager, which is a paging &lt;code&gt;UIScrollView&lt;/code&gt; on iOS and a &lt;code&gt;ViewPager2&lt;/code&gt; on Android.&lt;/p&gt;

&lt;p&gt;The active page's vertical scroll view is found natively and observed. Its offset, clamped to the header height, becomes the bands' translation, applied inside the &lt;code&gt;UIScrollViewDelegate&lt;/code&gt; or &lt;code&gt;OnScrollChangeListener&lt;/code&gt; callback. There is nothing per frame on the JS thread, so JS load cannot desynchronise anything.&lt;/p&gt;

&lt;p&gt;Everything else is the long tail of making that feel right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Neighbouring pages are pre-aligned to the header during a swipe, so a page never slides in at the wrong offset.&lt;/li&gt;
&lt;li&gt;A lazy page mounts the moment it peeks into view, not when the swipe settles, and is aligned as its content grows.&lt;/li&gt;
&lt;li&gt;Vertical drags on the header itself scroll the active list, with a display-link fling on iOS. Horizontal lists inside the header keep their own gestures.&lt;/li&gt;
&lt;li&gt;Tabs with little or no content still collapse the header. Native gives a short page exactly the scroll range it lacks.&lt;/li&gt;
&lt;li&gt;Pull-to-refresh belongs to the container, so it arms only when the header is fully open and the list is at its top.&lt;/li&gt;
&lt;li&gt;Presses under a finger that scrolled are cancelled, so a swipe that ends on a button does not tap it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it looks like in code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FlashList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@shopify/flash-list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;CollapsibleTabView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;createTabList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TabScrollView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-collapsible-tabs-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TabFlashList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTabList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FlashList&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;about&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;About&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProfileScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CollapsibleTabView&lt;/span&gt;
      &lt;span class="na"&gt;navigationState&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;routes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onIndexChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setIndex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;renderHeader&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProfileHeader&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;renderScene&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TabFlashList&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;renderItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;renderPost&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TabScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;About&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;TabScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createTabList&lt;/code&gt; wraps any list that renders a React Native &lt;code&gt;ScrollView&lt;/code&gt;, so &lt;code&gt;FlatList&lt;/code&gt;, &lt;code&gt;SectionList&lt;/code&gt;, FlashList and LegendList all work as pages. It pads the content under the header for you. That is the one rule.&lt;/p&gt;

&lt;p&gt;Beyond the basics there is an imperative &lt;code&gt;ref&lt;/code&gt; (&lt;code&gt;scrollToTop&lt;/code&gt;, &lt;code&gt;collapse&lt;/code&gt;, &lt;code&gt;expand&lt;/code&gt;, &lt;code&gt;setIndex&lt;/code&gt;), a &lt;code&gt;collapseMode="direction"&lt;/code&gt; for the home-feed feel where any up-scroll reveals the header, a &lt;code&gt;headerMinHeight&lt;/code&gt; for a strip that stays pinned, and two opt-in per-frame events for Reanimated worklets: &lt;code&gt;onPageScroll&lt;/code&gt; for a tab indicator that tracks the finger, and &lt;code&gt;onHeaderOffsetChange&lt;/code&gt; for a header that shrinks its own avatar. Neither touches the JS thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;I would rather you know these before installing than after.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is Fabric only. No Paper. React Native 0.80 and up.&lt;/li&gt;
&lt;li&gt;Horizontal swipes that start on the header are inert. Swipe on the content.&lt;/li&gt;
&lt;li&gt;Pull-to-refresh uses the platform's own indicator. You can style it and hide it, but a fully custom native-driven indicator is not there.&lt;/li&gt;
&lt;li&gt;The header collapses as one band. Sticky sections inside a page are the list's job, and they work under the bands.&lt;/li&gt;
&lt;li&gt;No web, no Expo Go. It works in Expo dev clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn add react-native-collapsible-tabs-native
&lt;span class="nb"&gt;cd &lt;/span&gt;ios &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pod &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo has an example app for both platforms and a README that spends as much space on limitations and FAQ as on features. The native code is two commented files, one Swift and one Kotlin, and I would genuinely like eyes on them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/ramssutharr/react-native-collapsible-tabs-native" rel="noopener noreferrer"&gt;https://github.com/ramssutharr/react-native-collapsible-tabs-native&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/react-native-collapsible-tabs-native" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/react-native-collapsible-tabs-native&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a profile screen with the gap, I would like to hear whether this closes it for you.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>opensource</category>
      <category>performance</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
