<?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: Jassi Singh</title>
    <description>The latest articles on DEV Community by Jassi Singh (@scanner).</description>
    <link>https://dev.to/scanner</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%2F3475371%2Fa0bd897c-9b72-4222-b4c9-ab0918937ed0.jpg</url>
      <title>DEV Community: Jassi Singh</title>
      <link>https://dev.to/scanner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scanner"/>
    <language>en</language>
    <item>
      <title>Why collapsible tab headers jump in React Native (and how to fix it)</title>
      <dc:creator>Jassi Singh</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/scanner/why-collapsible-tab-headers-jump-in-react-native-and-how-to-fix-it-5d4f</link>
      <guid>https://dev.to/scanner/why-collapsible-tab-headers-jump-in-react-native-and-how-to-fix-it-5d4f</guid>
      <description>&lt;p&gt;You've seen this screen a hundred times: a profile header, a row of tabs underneath (Posts, Media, Likes), and the header collapses as you scroll. Twitter, Instagram, Spotify artist pages.&lt;/p&gt;

&lt;p&gt;Now try this on most implementations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scroll &lt;strong&gt;Posts&lt;/strong&gt; down a few screens. The header collapses.&lt;/li&gt;
&lt;li&gt;Switch to &lt;strong&gt;Media&lt;/strong&gt;, which is still at the top.&lt;/li&gt;
&lt;li&gt;Watch the header &lt;strong&gt;jump&lt;/strong&gt;: snap open, snap closed, or leave a gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I hit this enough times that I ended up writing my own library. Here's what's actually going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: the header is derived from the active tab's scroll
&lt;/h2&gt;

&lt;p&gt;The common approach is simple: &lt;code&gt;headerTranslateY = -clamp(activeTab.scrollY, 0, headerHeight)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It works perfectly with one tab. With several, every tab has its &lt;strong&gt;own&lt;/strong&gt; scroll offset, and the header position is a function of whichever tab is active. Switch tabs and the input changes instantly, so the output (your header) teleports.&lt;/p&gt;

&lt;p&gt;Libraries usually patch around it by force-syncing scroll offsets on every tab switch (scrolling the new tab to match the header). That's where you get flicker, lost scroll positions, and fights with list virtualization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: decouple the header from tab offsets
&lt;/h2&gt;

&lt;p&gt;Instead of deriving the header from a scroll offset, make the header position &lt;strong&gt;its own piece of state&lt;/strong&gt; (a Reanimated shared value), and drive it with scroll &lt;em&gt;deltas&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrolling down&lt;/strong&gt; collapses the header in sync with content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrolling up&lt;/strong&gt; keeps the header collapsed until the content's top edge reaches it, then expands it in sync. The header never detaches from content and never leaves a gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching tabs&lt;/strong&gt; doesn't touch the header at all. Each tab keeps its own scroll position, and the header stays exactly where it was.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No offset-syncing, no jump, and every tab remembers where you left it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trade-off (there's always one)
&lt;/h3&gt;

&lt;p&gt;Because each tab keeps its own scroll memory, a deep-scrolled tab can have content sitting &lt;em&gt;underneath&lt;/em&gt; an expanded header. So the header needs a &lt;strong&gt;solid background&lt;/strong&gt;; translucent headers don't work with this model. I think that's the right trade for jump-free switching, but it's worth knowing upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice
&lt;/h2&gt;

&lt;p&gt;▶️ &lt;strong&gt;&lt;a href="https://github.com/user-attachments/assets/f7fdfd2b-8899-4192-9343-120e3c7b505b" rel="noopener noreferrer"&gt;Watch the demo: switching tabs with zero header jump&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I packaged this approach as &lt;a href="https://rnct.scannertechs.com" rel="noopener noreferrer"&gt;react-native-collapsible-tab&lt;/a&gt;. It's built on Reanimated, Gesture Handler and Pager View, with all animation on the UI thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;react-native-collapsible-tab
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-reanimated react-native-gesture-handler react-native-pager-view
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;Tabs&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-tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;()&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Container&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="c1"&gt;// height measured automatically&lt;/span&gt;
      &lt;span class="na"&gt;minHeaderHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;insets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;// stays visible when collapsed&lt;/span&gt;
      &lt;span class="na"&gt;lazy&lt;/span&gt;                                   &lt;span class="c1"&gt;// mount tabs on first focus&lt;/span&gt;
      &lt;span class="na"&gt;snapThreshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;                    &lt;span class="c1"&gt;// settle fully open/closed on release&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Posts"&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FlatList&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="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;item&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;Post&lt;/span&gt; &lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&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;keyExtractor&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;item&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"media"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Media"&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&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;MediaGrid&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tab&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Container&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;A few things I cared about while building it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Twitter-style reveal&lt;/strong&gt;: &lt;code&gt;revealHeaderOnScroll&lt;/code&gt; brings the header back on any upward scroll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snap without deadlocks&lt;/strong&gt;: snap is a plain shared-value animation, not offset-driven scrolling, which is known to lock up the UI thread on the New Architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FlashList v2 and LegendList adapters&lt;/strong&gt;, plus FlatList, ScrollView and SectionList.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windowed memory&lt;/strong&gt;: with React 19.2's &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;, &lt;code&gt;windowConfig={{ ahead: 1, behind: 1 }}&lt;/code&gt; keeps 60 tabs from all staying mounted. Only the focused one and its neighbours stay live, and scroll positions survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One gotcha that bites everyone: negative scroll offsets
&lt;/h2&gt;

&lt;p&gt;If you animate anything from scroll position (a progress bar, a fade), remember that on iOS &lt;code&gt;contentOffset.y&lt;/code&gt; goes &lt;strong&gt;negative&lt;/strong&gt; when the list bounces past the top. Android clamps at 0. Clamp both ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ negative offset → negative width during bounce&lt;/span&gt;
&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ overscroll reads as 0%&lt;/span&gt;
&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;TOTAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bounces={false}&lt;/code&gt; hides the symptom; clamping is the actual fix.&lt;/p&gt;




&lt;p&gt;Docs: &lt;a href="https://rnct.scannertechs.com" rel="noopener noreferrer"&gt;rnct.scannertechs.com&lt;/a&gt; · GitHub: &lt;a href="https://github.com/JassiSingh08/react-native-collapsible-tab" rel="noopener noreferrer"&gt;JassiSingh08/react-native-collapsible-tab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've fought this bug with a different approach, I'd love to hear how you solved it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
