Top tabs

Free · MITReact Native · Expo

A swipeable pager under a tab strip whose indicator tracks the swipe continuously: the leading edge runs ahead and the trailing edge follows, so it stretches between tabs and gathers as the page lands. Labels recolor with the swipe and a scrolling strip keeps the indicator centred.

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/top-tabs.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/top-tabs.

Usage

import { View } from "react-native";
import { TopTabs } from "@/components/goodui-native/top-tabs/top-tabs";

export function OrdersScreen() {
  return (
    <View style={{ flex: 1 }}>
      <TopTabs
        accessibilityLabel="Orders"
        style={{ flex: 1 }}
        tabs={[
          { key: "upcoming", title: "Upcoming", badge: 2, content: <UpcomingOrders /> },
          { key: "past", title: "Past", content: <PastOrders /> },
          { key: "cancelled", title: "Cancelled", content: <CancelledOrders /> },
        ]}
      />
    </View>
  );
}

When to use

Use it for
  • Sibling lists of one screen the person swipes between: upcoming, past and cancelled orders; all, unread and mentions.
  • Many categories of a feed in a scrolling strip: for you, trending, food, travel.
  • Profile or detail screens with sections that each scroll on their own.
Reach for something else
  • App-level destinations: use tab-bar at the bottom.
  • Two to five filters of the same list without swiping between pages: use segmented-control.
  • A row of cards or images to browse: use carousel.
  • Many filters that combine: use chip.

Examples

Scrolling strip with a pill

<TopTabs
  variant="pill"
  size="sm"
  accessibilityLabel="Categories"
  style={{ flex: 1 }}
  tabs={categories.map((c) => ({ key: c.id, title: c.name, content: <Feed category={c.id} /> }))}
/>

Controlled

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

<TopTabs value={tab} onValueChange={setTab} style={{ flex: 1 }} tabs={tabs} />
<Button onPress={() => setTab("mentions")}>Show mentions</Button>

API reference

PropTypeDefault
tabs*
Two or more tabs, in order.
TopTab[]—
value
Selected tab key (controlled). Pair it with `onValueChange`.
string—
defaultValue
Selected tab key on first render (uncontrolled). Default the first tab.
string—
onValueChange
Called when a tap or a swipe settles on a different tab.
(key: string) => void—
variant
`underline` draws an accent line under the label; `pill` an accent capsule behind it.
"underline" | "pill""underline"
size
`md` 48 pt tabs with subhead labels, `sm` 44 pt with footnote labels.
"sm" | "md""md"
scrollable
Scrolling strip of auto-width tabs instead of equal columns. `auto` (default) scrolls with more than four tabs.
boolean | "auto""auto"
swipeEnabled
Swipe between pages. Default `true`.
booleantrue
lazy
Mount a page only once it or a neighbour has been shown. Default `true`.
booleantrue
haptic
Haptic when the tab changes. `false` turns it off.
HapticName | false"select"
accessibilityLabel
Name of the tab list, for example "Orders".
string—
style
Style of the whole component. Give it `flex: 1` (or a height) when pages scroll.
StyleProp<ViewStyle>—
tabBarStyle
Style of the tab strip.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Pages follow the finger 1:1 with resistance past the first and last page, and settle on the smooth spring carrying the release velocity, at most one page per swipe.
  • The indicator is computed from the fractional page: its leading edge eases out and its trailing edge eases in between the two tabs' measured positions, so it stretches mid-swipe and lands exactly on the label (underline) or the tab (pill).
  • Labels recolor by distance to their page, from secondary to the selected color.
  • A scrolling strip scrolls each frame to keep the indicator centred.
  • Tapping a tab glides the pager on the smooth spring, which carries the indicator with it.
  • Reduced motion: tapping a tab swaps the page and fades it up instead of sliding; a swipe still follows the finger and settles with a short timing.
  • Haptic: select when a tap or a swipe settles on a different tab (configurable with haptic)
  • The pager, indicator, label colors and strip scrolling all run on the UI thread from one shared value; React state changes once per settled page.
  • With lazy (default) a page mounts the first time it or a neighbour is current, and stays mounted afterwards to keep its scroll position.

Accessibility

  • The strip is a tablist named by accessibilityLabel; each tab has the tab role with selected in accessibilityState and aria-selected on the web.
  • Only the current page is exposed to screen readers. On the web the other pages are tabpanels made inert, so their controls also leave the tab order.
  • Tab badges are read with the tab on the web and hidden as decoration on native (put the count in the title if it matters there).
  • Tabs are at least 44 pt tall and as wide as their label plus padding.

Notes for AI

  • Give TopTabs style={{ flex: 1 }} (inside a bounded parent) when pages contain their own ScrollView or FlatList; otherwise pages size to the tallest content.
  • content is any node; put the page's ScrollView inside it. Do not nest TopTabs pages in a horizontal ScrollView.
  • Keep titles short; with more than four tabs (scrollable='auto') the strip scrolls and tabs size to their labels.
  • Pressables inside a page take the pointer on the web, so a swipe that starts on a button does not move the pager there; swipes on the page background work.
  • Use badge for counts; it hides at 0.