Tab bar
Free · MITReact Native · ExpoA 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.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/tab-bar.jsonThen 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
- 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).
- 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
| Prop | Type | Default |
|---|---|---|
| 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. | boolean | true |
| 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. | boolean | false |
| 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. | boolean | true |
| absolute Pin the bar to the bottom of its parent so content scrolls under it. Use it with `hideOnScroll`. | boolean | false |
| draggable Allow dragging the pill along the bar to scrub between tabs. Default `true`. | boolean | true |
| 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
| Prop | Type | Default |
|---|---|---|
| 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.
Related
Two to five views of the same thing with an elastic highlight that stretches toward the new segment, can be dragged, and ticks at every boundary. Includes a billing toggle.
An iOS large title that collapses into a compact bar as the content scrolls: the titles cross-fade, the bar fades in, the title stretches when pulled past the top and a half-finished collapse settles on either state.
A sheet that rests at detents, follows the finger 1:1, settles with velocity and hands drags off to its scroll view.