Tab bar

Free · MITReact Native · Expo

A bottom tab bar whose pill stretches toward the next tab and can be dragged to scrub between tabs; the selected icon bounces, badges pop, and it can hide while scrolling.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/tab-bar.json

Then run npx expo install --fix so react-native-gesture-handler, react-native-reanimated match your Expo SDK. Files land in components/goodui-native/tab-bar.

Usage

import { Tabs } from "expo-router";
import { NavigationTabBar } from "@/components/goodui-native/tab-bar/tab-bar";

export default function TabsLayout() {
  return (
    <Tabs tabBar={(props) => <NavigationTabBar {...props} variant="floating" />} screenOptions={{ headerShown: false }}>
      <Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: ({ color, size }) => <HomeIcon color={color} size={size} /> }} />
      <Tabs.Screen name="inbox" options={{ title: "Inbox", tabBarBadge: 3, tabBarIcon: ({ color, size }) => <InboxIcon color={color} size={size} /> }} />
      <Tabs.Screen name="profile" options={{ title: "Profile", tabBarIcon: ({ color, size }) => <UserIcon color={color} size={size} /> }} />
    </Tabs>
  );
}

When to use

Use it for
  • Top-level navigation between three to five sections of an app: home, search, inbox, profile.
  • As the custom tabBar of expo-router Tabs through NavigationTabBar.
  • Long feeds where the bar should step aside while reading (pass scrollY).
Reach for something else
  • Switching views of the same content inside a screen: use segmented-control.
  • More than five destinations: move the rest into a profile or more screen.
  • Actions such as compose or add: use a button or a trailing action in large-title-header.

Examples

Standalone, controlled, with badges

import { TabBar } from "@/components/goodui-native/tab-bar/tab-bar";

const [tab, setTab] = useState("chats");

<TabBar
  accessibilityLabel="Main"
  value={tab}
  onValueChange={setTab}
  onReselect={() => listRef.current?.scrollToOffset({ offset: 0 })}
  items={[
    { value: "chats", label: "Chats", icon: (p) => <ChatIcon {...p} />, badge: unread },
    { value: "calls", label: "Calls", icon: (p) => <PhoneIcon {...p} />, badge: true },
    { value: "profile", label: "Profile", icon: (p) => <UserIcon {...p} /> },
  ]}
/>

Hide while scrolling

const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((e) => {
  scrollY.value = e.contentOffset.y;
});

<View style={{ flex: 1 }}>
  <Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16} contentContainerStyle={{ paddingBottom: 120 }}>
    {content}
  </Animated.ScrollView>
  <TabBar variant="floating" absolute scrollY={scrollY} items={items} defaultValue="today" />
</View>

API reference

TabBar

PropTypeDefault
items*
Two to five tabs.
TabBarItem<T>[]—
value
The selected tab (controlled).
T—
defaultValue
The tab selected first when uncontrolled. Defaults to the first item.
T—
onValueChange
Called with the new tab on tap, or on release after dragging the pill.
(value: T) => void—
onReselect
Called when the selected tab is tapped again, for example to scroll to the top.
(value: T) => void—
variant
`docked` spans the bottom edge with a hairline; `floating` is an inset capsule above the home indicator.
"docked" | "floating""docked"
showLabels
Show labels under the icons. With `false` the bar is 52 pt tall and icon only.
booleantrue
haptic
Haptic when the tab changes. `false` turns it off.
HapticName | false"select"
hidden
Hide the bar (slides below the screen edge). Overrides the scroll behavior while true.
booleanfalse
scrollY
Vertical scroll offset of the active screen. With `hideOnScroll` the bar hides while scrolling down.
SharedValue<number>—
hideOnScroll
Hide while scrolling down and show again on scrolling up or near the top. Default `true` when `scrollY` is passed.
booleantrue
absolute
Pin the bar to the bottom of its parent so content scrolls under it. Use it with `hideOnScroll`.
booleanfalse
draggable
Allow dragging the pill along the bar to scrub between tabs. Default `true`.
booleantrue
style
Style for the outer container.
StyleProp<ViewStyle>—
accessibilityLabel
Overrides the label read by screen readers.
string—
testID
Identifier for end-to-end tests.
string—

NavigationTabBar

PropTypeDefault
state*{ index: number; routes: { key: string; name: string; params?: object }[] }—
descriptors*Record<string, { options: NavTabOptions }>—
emitter
expo-router: emits tabPress so listeners and scroll-to-top keep working.
{ emit(event: TabPressEvent): { defaultPrevented?: boolean } }—
navigateToTab
expo-router: switches to the tab with this route key.
(routeKey: string) => void—
navigation
React Navigation 7.
{ emit(event: TabPressEvent): { defaultPrevented?: boolean }; navigate(name: string, params?: object): void; }—
style
Style for the outer container.
StyleProp<ViewStyle>—
variant
`docked` spans the bottom edge with a hairline; `floating` is an inset capsule above the home indicator.
"docked" | "floating"—
scrollY
Vertical scroll offset of the active screen. With `hideOnScroll` the bar hides while scrolling down.
SharedValue<number>—
showLabels
Show labels under the icons. With `false` the bar is 52 pt tall and icon only.
boolean—
haptic
Haptic when the tab changes. `false` turns it off.
HapticName | false—
hidden
Hide the bar (slides below the screen edge). Overrides the scroll behavior while true.
boolean—
hideOnScroll
Hide while scrolling down and show again on scrolling up or near the top. Default `true` when `scrollY` is passed.
boolean—
absolute
Pin the bar to the bottom of its parent so content scrolls under it. Use it with `hideOnScroll`.
boolean—
draggable
Allow dragging the pill along the bar to scrub between tabs. Default `true`.
boolean—

Motion and haptics

  • Selection: the pill's leading edge travels on the snappy spring and the trailing edge on the smooth spring, so it stretches toward the new tab and settles.
  • Drag: the pill follows the finger 1:1 along the bar, clamped to the ends; release projects the velocity and snaps to the nearest tab.
  • Icon and label colors cross-fade in proportion to how much of the pill covers each tab, so they follow a dragged pill.
  • The newly selected icon dips to 0.8 and springs back on the bouncy spring; a badge count that goes up pops to 1.35 and settles.
  • Hide on scroll: after 24 pt of downward scrolling the bar slides below the edge on the smooth spring; 24 pt upward or reaching the top brings it back.
  • Reduced motion: the pill jumps instead of stretching, no icon bounce or badge pop, and hiding becomes a fade.
  • Haptic: select when the tab changes on tap
  • Haptic: select on every tab boundary while dragging the pill (the release itself is quiet)
  • Pill, colors, bounce and hiding run on the UI thread; React renders only when the value or the items change.
  • Each icon renders twice (on and off colors) for the cross-fade, so keep icon components light.

Accessibility

  • The row has accessibilityRole tablist; each tab is a tab with accessibilityState selected (aria-selected on the web).
  • Tab labels include the badge: 'Chats, 3 new', or 'Calls, new' for a dot. Override with accessibilityLabel per item.
  • Every tab cell is at least 44 pt tall and fills an equal share of the width.
  • On the web tabs are focusable; Enter and Space select.
  • On the web a tab is named by its visible label plus visually hidden badge text, and the drawn badge is aria-hidden.

Notes for AI

  • With expo-router use NavigationTabBar in Tabs' tabBar prop; it reads title or tabBarLabel, tabBarIcon and tabBarBadge from the screen options and emits tabPress so scroll-to-top listeners still work.
  • icon is a render function receiving { color, size, focused }; color the icon with the color it receives.
  • badge: a number shows a count (99+ above 99), true shows a dot, 0 or false hides it.
  • For hideOnScroll pass the screen's scroll offset as a shared value and set absolute so content scrolls under the bar; add bottom padding to the content.
  • Do not wrap it in SafeAreaView: the docked bar pads itself with the bottom inset and the floating bar sits above it.