Coinbase Logo

Coinbase’s animated TabBar in React Native

By Jacob Thornton


, March 19, 2021

, 10 min read time


Introduction to the “TabBar” user experience

You’ve probably used this interaction countless times in your day to day life and not spent much time thinking about it. You see it on things like Instagram, Twitter, Apple Music, AND most recently on the Coinbase prices screen.

It’s a simple tab component, with a scroll-away Header and TabBar that pins to the top of the screen. It allows you to swipe through the different TabViews, and it treats the overall scroll position intelligently (i.e. if you scroll past the Header on one TabView, switching tabs keeps the global header in the same position, irrespective of tab scroll).

However, despite how universal this UX experience has become, behind the scenes it still requires a large lift of complex gestures and state management to get it feeling and performing “just right” for the end user.

At Coinbase we were able to pull this off for our recent React Native rewrite and wanted to share a little bit more about the UX details that went into this interaction, as well as the code needed to make it happen.

Glossary of UX terms

Before we get started, let’s define some terminology that will help us describe the core components needed for pulling off this experience. We also extracted a simple companion example project — which you can use to follow along or help build your own working version of this experience.

  1. Header This is the top typographic lockup on the Screen.

  2. TabBar This is a list of tab buttons. It highlights the active tab and allows you to navigate between each TabView with a tap or swipe.

  3. TabView This is the scrollable tab content. This can be asset cells, tweets, photos, or any other content.

  4. NavBar This is the top screen navigation. You often see this with back arrows or other icons up here as well. But to keep it simple we just have a title.

  5. Screen — The top level container component (root level React component).

Beyond components we will also be referring to the following terms:

  1. Interpolation — Maps input ranges to output ranges, typically using a linear interpolation but also supports easing functions.

  2. FPS — Frames per second.

  3. Bridge Traffic — Messages being sent over React Native’s bridge between its javascript and native layer.

  4. Active TabView — The currently visible TabView.

  5. Inactive TabView — TabViews that are off screen (not currently visible).

Guide to interactions


There are a handful of key interactions that go into making the TabView look and feel organic. Many of these behaviors were identified through studying external implementations like Twitter and Instagram, as well as platform specific applications and specifications like Apple’s HIG and Google’s Material Design.

Here is a list of key interactions (all of which you will find in our sample project on github). Study the gif above to see if you can pick out each of these subtle behaviors.

  1. The NavBar should fade in/out with a timer based animation when the scroll reaches a certain position. Many people make the mistake of interpolating the NavBar title opacity based on the page’s scroll position. While this works really well when scrolling slowly, if you scroll more quickly (standard in day-to-day use of applications) you’ll notice the NavBar title will often transition in too abruptly. We first noticed this behavior when analyzing Apple Note’s UI, but you’ll see in all of Apple’s native applications (Apple Music, Mail, etc.) that they implement this same fade trigger point. The ideal trigger point for this animation is right as the Header title is obstructed by the NavBar.

  2. The Header should slide up and off the screen (beneath the NavBar) on scroll. This might seem obvious, but getting this right on React Native can be quite challenging. One important limitation of React Native is that there are no nested scroll surfaces. This has major consequences: a. Because TabViews must be independently scrollable (with their own independent scroll positions), the top-level Screen container cannot be scrollable. b. Because the top-level Screen container cannot be scrollable, we must simulate the effect that the top of the page is “scrolling” by interpolating the active TabView’s scroll position to a translateY offset for the Header. This transform gives the visual appearance of the Header scrolling with the TabBar and TabViews up and off the page, without actually scrolling the page (more on managing scroll position, and the complexity that comes with that in the implementation details below).

  3. The TabBarshould slide up and then pin to the bottom edge of the NavBar. Similar to the Header, the TabBar must be slid up the screen using translateY. Unlike with Header, we need to cap the total distance the component can travel up the screen, to give the effect that the tabs are pinned beneath the NavBar.

  4. Once the TabBar is pinned, a bottom border should be faded in to add depth to TabViews sliding beneath it. Unlike with the NavBar, the bottom border opacity should be interpolated in direct correlation to the scroll position. The reason we do this is to guarantee that the border is always opaque whenever TabViewis passing under TabBar.

  5. TabView should scroll up and pin directly beneath the TabBar, ensuring that anytime you swipe or navigate between TabViews, the header position is always correctly maintained. Like TabBar, Tabview should appear visually pinned beneath TabBar. Once pinned, the TabView content should appear to scroll beneath the pinned TabBar.


Mobile users are accustomed to smooth and well-designed interfaces that quickly respond to their interactions and provide prompt visual feedback. At Coinbase we use custom tooling to track and measure our fps, aiming to keep key interactions like navigation, gestures, etc. at 60fps. In order to do this in React Native, it’s imperative that we both limit bridge traffic (reducing JS-driven animations) and reduce render cycles (minimally update state/context api).

Performance concerns, coupled with React Native’s limitation around no nested scroll surfaces, means our best option for powering the above interactions is to manage a single, shared Animated.Value (that we call ScrollY).

To populate our Animated.Value we use React Native’s event method and native driver, adding the below snippet to TabView’s onScroll event.

const handleScroll = scrollY && Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true, });

Note: We update the scrollY event here based on whether a given tab is active. This makes sure there is only ever a single TabView updating scroll positions at any given time.

Now that we have ScrollY management locked in, we can begin to interpolate its value across our different components to achieve the interactions covered above.

  1. The NavBar title should fade in/out with a timer based animation when the scroll reaches a certain position.

Here we set a listener for our scrollY value change. When this value changes, if the scroll distance covered is 60% of the Header (meaning the header has traveled upward beneath the NavBar and 60% of its height is covered by the NavBar), we trigger a 300ms native opacity animation for the NavBar title. Because our Navbar is fixed to the top of the Screen, we don’t have to worry about any complex positioning or offset logic.

const [showTitle, setShowTitle] = useState(false); const opacity = useRef(new Animated.Value(0)).current;

useEffect(() => { Animated.timing(opacity, { toValue: showTitle ? 1 : 0, duration: 300, useNativeDriver: true, }).start(); }, [opacity, showTitle]);

useEffect(() => { const listener = scrollY?.addListener(({ value }) => { setShowTitle( value > CBTabViewOffset + sizing.header * 0.6 ); });

return () => { scrollY?.removeListener(listener); }; });

2. The Header should slide up and off the screen (beneath the NavBar) on scroll.

The React Native interpolation API takes a value that changes over time and interpolates it based on a range of inputs (e.g. 0 to 1) and outputs it to a given range (e.g. 0 to 100). So for example, if we had an Animated.Value updated to 0.3 and ran through the below interpolation, the interpolated output would be 30.

value.interpolate({ inputRange: [0, 1], outputRange: [0, 100] });

With this in mind, let’s consider our Header interpolation.

const translateY = scrollY.interpolate({ inputRange: [ CBTabViewOffset, CBTabViewOffset + sizing.header, ], outputRange: [0, -sizing.header], extrapolateLeft: "clamp", });

Here we are interpolating a ScrollY value that should theoretically have an input range of 0 (top of scroll) and our Header height (let’s say 80pt).

There is a bit of nuance here however because of how iOS treats 3 scenarios: pull to refresh, rubberbanding, and overscroll.

Inside our TabView we provide special contentInset and contentOffset patterns for iOS. This allows our “pull to refresh” spinner to correctly pull down beneath our Header (visually at the top of TabView).


Note: We are able to achieve the same affect in a simpler manner on Android by simply applying a paddingTop to our contentContainers style. We are able to get away with this on Android because it doesn’t have overscroll.

… contentInset={{ ios: { top: sizing.header } })} contentOffset={{ ios: { x: 0, y: -sizing.header, }, })} contentContainerStyle={{ ios: { flexGrow: 1, paddingBottom: spacing.gutter, }, android: { flexGrow: 1, paddingTop: sizing.header, paddingBottom: spacing.gutter, }, })} …

When returning to our Header interpolation, the inputRange used must be different for iOS (to account for the contentOffset). Specifically:

exportconstCBTabViewOffset=Platform.OS==="ios" ? sizing.header : 0;

The final thing to note here is that we are interpolating those input values (ScrollOffset to ScrollOffset + Header) to an output range of (0 to negative Header height). And we’re using React’s interpolate “clamp” value to prevent the output value from exceeding outputRange (whereas the default is “extend” which will exceed our outputRange).

The result is that as you scroll TabView the header will be transformed up and off the screen until it is completely hidden (negative Header), and no further.

const translateY = scrollY.interpolate({ inputRange: [ CBTabViewOffset, CBTabViewOffset + sizing.header, ], outputRange: [0, -sizing.header], extrapolateLeft: "clamp", });

3. The TabBarshould slide up and then pin to the bottom edge of the NavBar.

By default, the TabBar is rendered inside of the react-native-tab-view’s TabView component using { position absolute, top: 0 }, which effectively gives us the visual “pinning” effect.

The trick here is we’re using interpolation to first push the TabView further down the screen then it’s natural render position (unlike we did with our Header component), and then using ScrollY change events to transform it back to it’s natural 0 placement. This is why our output range in the below invocation sets an initial transform of the height of the Header, and translates it up into it’s pinned final pinned position of 0.

const translateY = scrollY.interpolate({ inputRange: [ CBTabViewOffset, CBTabViewOffset + sizing.header, ], outputRange: [sizing.header, 0], extrapolate: "clamp", }); view raw

4. Once the TabBar is pinned, a bottom border should be faded in to add depth to TabViews sliding beneath it.

To fade the border in, we set an input range from the top of the Header to the Header + TabBar Height. This ensures that the border doesn’t begin fading in until the header has been scrolled out of view, and that it is completely faded in by the time the TabView children scroll beneath it.

We then interpolate the value to an outputRange of 0 to 1, which is the value range for the opacity property in React Native.

const borderOpacity = scrollY.interpolate({ inputRange: [ CBTabViewOffset + sizing.header, CBTabViewOffset + sizing.header + sizing.tabBar, ], outputRange: [0, 1], extrapolateRight: "clamp", });

5. TabView should slide up and pin directly beneath the TabBar, ensuring that anytime you swipe or navigate between TabViews, the header height is always correctly maintained

This is arguably the most difficult piece of this entire architecture. Relying on a single ScrollY value greatly simplifies the above interpolations, however it becomes problematic as soon as you begin to take into account inactive TabViews. If you aren’t doing something to proactively synchronize inactive scroll views, then navigating between TabViews will display scroll positions at incorrect placements (as the inactive TabViews won’t have compensated for the Header being shown or hidden).

To solve this we must populate the following 4 variables.

  1. index The active tab index, provided by our Tab library react-native-tab view.


2. tabKey The tab identifier, provided by our Tab library react-native-tab-view and passed down to individual TabView components.


3. tabkeyToScrollableChildRef This is a map of tab identifiers to scrollable component refs (TabViews). It will be used later to passively update all inactive TabView scroll offsets.

consttabkeyToScrollableChildRef=useRef<{[key: string]: FlatList}>({}).current;

4. tabkeyToScrollPosition This is a map of tab identifiers to their local scroll position. It will be used to compare cached scrollPositions to Header offsets to determine if inactive TabViews scroll offsets need to be adjusted.

consttabkeyToScrollPosition=useRef<{[key: string]: number}>({}).current;

To begin synchronizing scroll offsets, we first start by populating tabkeyToScrollableChildRef using React’s special ref prop. When you pass a method to the ref prop in React, the incoming method is invoked with a reference to the component. We then take this reference, along with a TabView’s local TabKey reference (passed in by react-native-tab-view), and call up to our trackRef method.

const trackRef = (key: string, ref: FlatList) => { tabkeyToScrollableChildRef[key] = ref; };

<Animated.FlatList<T> ref={(ref: any) => { trackRef(tabKey, ref); }} ... />

Next we populate our tabKeyToScrollPosition map by listening in on our global ScrollY value and inferring the activeTab by mapping index to our tabs array. This allows us to know where all of our tabs are scrolled (even inactive tabs) at any given time.

useEffect(() => { const listener = scrollY.addListener(({ value }) => { const activeTab = tabs[index].key; tabkeyToScrollPosition[activeTab] = value; }); return () => { scrollY.removeListener(listener); }; }, [index, scrollY, tabs, tabkeyToScrollPosition]);

The final step to synchronize scroll offsets is to make use of onMomentumScrollEnd and onScrollEndDrag eventsto invoke a custom method we call syncScrollOffset.

SyncScrollOffset iterates through inactive TabKeys and updates their scroll offsets depending on if the Header is scrolled in or out of view. Doing so will ensure that anytime you swipe or navigate between TabViews, the header height is always correctly maintained.

const syncScrollOffset = () => { const activeTabKey = tabs[index].key; const scrollValue = tabkeyToScrollPosition[activeTabkey];

Object.keys(tabkeyToScrollableChildRef).forEach((key) => { const scrollRef = tabkeyToScrollableChildRef[key]; if (!scrollRef || key === activeTabKey) { return; }

if (scrollValue <= animatedTabSceneOffset + sizing.header) { /* header visible */ scrollRef.scrollToOffset({ offset: Math.max( Math.min(scrollValue, animatedTabSceneOffset + sizing.header), animatedTabSceneOffset, ), animated: false, }); tabkeyToScrollPosition[key] = scrollValue; } else if ( tabkeyToScrollPosition[key] < animatedTabSceneOffset + sizing.header || tabkeyToScrollPosition[key] == null ) { /* header hidden */ scrollRef.scrollToOffset({ offset: animatedTabSceneOffset + sizing.header, animated: false, }); tabkeyToScrollPosition[key] = animatedTabSceneOffset + sizing.header; } }); };


At Coinbase, we’re spending a lot of time getting these interactions as close to perfect as possible. An important part about our transition away from Native technologies to React Native has been about not only maintaining our quality bar, but raising it. This happens through deep UX investments, our multi-platform design system, and more.

If this type of work interests you, or if you have any questions about the above implementation, please don’t hesitate to reach out and check out our job board — we’re hiring!

And check out our previous posts on our React Native journey such as Onboarding thousands of users with React Native and Optimizing React Native.

This website contains links to third-party websites or other content for information purposes only (“Third-Party Sites”). The Third-Party Sites are not under the control of Coinbase, Inc., and its affiliates (“Coinbase”), and Coinbase is not responsible for the content of any Third-Party Site, including without limitation any link contained in a Third-Party Site, or any changes or updates to a Third-Party Site. Coinbase is not responsible for webcasting or any other form of transmission received from any Third-Party Site. Coinbase is providing these links to you only as a convenience, and the inclusion of any link does not imply endorsement, approval or recommendation by Coinbase of the site or any association with its operators.

All images provided herein are by Coinbase.

Coinbase logo