Tabs

A row of tabs and the panels they switch between, with a swipeable pager.

import { Tabs } from "@delacour/native-ui/tabs";
Every variant
Every variant
import { TABS_VARIANTS, Tabs } from "@delacour/native-ui/tabs";import { Text } from "@delacour/native-ui/text";import type { ReactElement } from "react";import { View } from "react-native";const PANELS = [	{ body: "Everything at a glance.", title: "Overview", value: "overview" },	{ body: "What changed, and when.", title: "Activity", value: "activity" },	{ body: "Nothing here is saved.", title: "Settings", value: "settings" },] as const;/** * One bar per variant, on the same three tabs. * * The panels carry a fixed height so the bars stay level down the demo — * without one, each pager takes the height of its own tallest panel and they * drift apart as you switch tabs. */export function Demo(): ReactElement {	return (		<View className="gap-6">			{TABS_VARIANTS.map((variant) => (				<View className="gap-2" key={variant}>					<Text.Caption size="xs">{variant}</Text.Caption>					<Tabs variant={variant}>						<Tabs.List>							<Tabs.Indicator />							{PANELS.map((panel) => (								<Tabs.Trigger key={panel.value} testID={`${variant}-${panel.value}`} value={panel.value}>									{panel.title}								</Tabs.Trigger>							))}						</Tabs.List>						{PANELS.map((panel) => (							<Tabs.Content key={panel.value} value={panel.value}>								<View className="h-16 justify-center rounded-lg bg-secondary px-4">									<Text.Paragraph>{panel.body}</Text.Paragraph>								</View>							</Tabs.Content>						))}					</Tabs>				</View>			))}		</View>	);}
<Tabs value={tab} onValueChange={setTab}>
  <Tabs.List>
    <Tabs.Indicator />
    <Tabs.Trigger value="all"><Tabs.Label>All</Tabs.Label></Tabs.Trigger>
    <Tabs.Trigger value="unread"><Tabs.Label>Unread</Tabs.Label></Tabs.Trigger>
  </Tabs.List>

  <Tabs.Content value="all">{…}</Tabs.Content>
  <Tabs.Content value="unread">{…}</Tabs.Content>
</Tabs>

Variants: primary — a fully rounded capsule inside a muted track — and secondary, an underline rule. Sizes: sm, md, lg: the trigger's floor and padding, the gap ladder, the label's Text step and the glyph step a composed Icon inherits, on one axis.

Anatomy

Every size
Every size
import { TABS_SIZES, Tabs } from "@delacour/native-ui/tabs";import { Text } from "@delacour/native-ui/text";import type { ReactElement } from "react";import { View } from "react-native";const PANELS = [	{ title: "Day", value: "day" },	{ title: "Week", value: "week" },	{ title: "Month", value: "month" },] as const;/** * One bar per size, with a 44pt rule drawn beside the default one. * * The rule is the check that matters here: `md`'s trigger floor has to clear the * platform hit target, and it is a floor rather than a height so a large * accessibility text step grows the row instead of clipping the label. */export function Demo(): ReactElement {	return (		<View className="gap-6">			{TABS_SIZES.map((size) => (				<View className="gap-2" key={size}>					<Text.Caption size="xs">{size}</Text.Caption>					<View className="flex-row items-start gap-3">						<View className="flex-1">							<Tabs size={size}>								<Tabs.List>									<Tabs.Indicator />									{PANELS.map((panel) => (										<Tabs.Trigger key={panel.value} testID={`${size}-${panel.value}`} value={panel.value}>											{panel.title}										</Tabs.Trigger>									))}								</Tabs.List>							</Tabs>						</View>						{size === "md" ? <View className="h-11 w-1 rounded-full bg-danger" /> : null}					</View>				</View>			))}		</View>	);}
PartWhat it is
TabsThe root. Owns the order walk, the value flow and the pan
Tabs.ListThe bar: the track, and the frame every trigger measures itself into
Tabs.ScrollViewA horizontal scroller for a bar with more tabs than room
Tabs.IndicatorThe one layer that slides. Write it out as the row's first child
Tabs.TriggerOne tab. A Pressable, so it inherits the whole vocabulary
Tabs.LabelA trigger's text. Bare string children become one automatically
Tabs.SeparatorA hairline between two tabs, which retreats as either is approached
Tabs.ContentOne panel. Must be a direct child of Tabs

The panels are the order of record

The root walks its own children for Tabs.Content, and their source order is the tab order. Triggers are not walked — each finds its place by looking its value up in that list, so a trigger can sit behind a wrapper or come out of a .map().

Tabs.Content must be a direct child of Tabs. Triggers may be nested; panels may not.

Writing the triggers in a different order from the panels puts the indicator on the wrong tab, with nothing in the source to point at — so the list checks the measured x values ascend and warns by name in development.

Everything moves off one shared value

position is a float index into the panels' order. A press springs it, a drag writes it directly, and the pager's translation, the indicator's frame and every separator's opacity are three readings of it. Two clocks is how a capsule ends up a frame behind the panel it is meant to be sitting on.

onValueChange fires once, when the finger lifts, before the spring finishes — so the trigger highlights immediately rather than 300ms later. It is never called for a re-press of the current tab.

value is string | null, never undefined

null means "controlled, nothing selected". Omitting the prop is what makes the bar uncontrolled, so a useState<string>() seeded with undefined would silently hand the component its own state and then switch it to controlled on the first press. Pass tab ?? null.

Swiping

isSwipeable is exactly one thing: whether the pan is enabled. It does not change what mounts. Every panel mounts either way — you cannot drag to a panel that is not there, and one that unmounted would lose what was typed into it.

There is no isLazy, because "lazy" is not one thing: a form wants to stay mounted, a video wants to stop, and a report wants never to have been built. Use Tabs.Content's function children for the per-panel decision.

The pan claims sideways and gives up vertical, so a pager works inside Screen.ScrollArea. A horizontal scrollable inside a panel is yours to settle — the pager publishes its pan on useTabsMotion(), and you write Gesture.Native().blocksExternalGesture(panGesture).

The indicator

Twelve tabs
Twelve tabs
import { Button } from "@delacour/native-ui/button";import { TABS_SCROLL_ALIGNS, Tabs, type TabsScrollAlign } from "@delacour/native-ui/tabs";import { Text } from "@delacour/native-ui/text";import { type ReactElement, useState } from "react";import { View } from "react-native";const SECTIONS = [	"Overview",	"Activity",	"Members",	"Billing",	"Integrations",	"Notifications",	"Security",	"Audit log",	"Webhooks",	"API keys",	"Domains",	"Danger zone",] as const;/** * Twelve tabs in a scroller, at every alignment. * * The two things to watch are the clamps: selecting the first tab must not scroll * the row past its own start, and selecting the last must not scroll past its * content. Both are the difference between a bar that settles and one that drifts * a few points every time you reach an end. */export function Demo(): ReactElement {	const [align, setAlign] = useState<TabsScrollAlign>("center");	const [value, setValue] = useState<string>(SECTIONS[0]);	return (		<View className="gap-3">			<View className="flex-row flex-wrap gap-2">				{TABS_SCROLL_ALIGNS.map((option) => (					<Button						key={option}						onPress={() => setAlign(option)}						size="sm"						testID={`align-${option}`}						variant={align === option ? "primary" : "outline"}					>						{option}					</Button>				))}			</View>			<Tabs onValueChange={setValue} value={value}>				<Tabs.List>					<Tabs.ScrollView scrollAlign={align}>						<Tabs.Indicator />						{SECTIONS.map((section) => (							<Tabs.Trigger key={section} testID={`section-${section}`} value={section}>								{section}							</Tabs.Trigger>						))}					</Tabs.ScrollView>				</Tabs.List>				{SECTIONS.map((section) => (					<Tabs.Content key={section} value={section}>						<View className="h-24 justify-center rounded-lg bg-secondary px-4">							<Text.Subheader>{section}</Text.Subheader>							<Text.Caption color="muted">Panel for {section}.</Text.Caption>						</View>					</Tabs.Content>				))}			</Tabs>		</View>	);}

It animates width and translateX, never scaleX. A scale is cheaper and wrong three times over: rounded-full stretches to an ellipse at the 2.3× a short tab to a long one asks for, a border thickens on two edges only, and any children you pass are squashed with it.

The cost is bounded because the indicator is absolutely positioned — its size change never dirties a sibling, so the row's layout stays settled.

A render prop must not change a trigger's size

A trigger's width is the indicator's geometry, so content that appears only on selection — a badge, a count — re-measures the row and shifts the whole bar on every tab change. Swap a treatment rather than adding or removing content.

Labels

Composed icons and a render prop
Composed icons and a render prop
import { Badge } from "@delacour/native-ui/badge";import { Icon } from "@delacour/native-ui/icon";import { IconBell, IconUser } from "@delacour/native-ui/icons/central";import { Tabs } from "@delacour/native-ui/tabs";import { Text } from "@delacour/native-ui/text";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<Tabs variant="secondary">			<Tabs.List>				<Tabs.Indicator />				<Tabs.Trigger testID="composed-alerts" value="alerts">					{({ isSelected }) => (						<>							<Icon icon={IconBell} />							<Tabs.Label>Alerts</Tabs.Label>							<Badge color="danger" size="sm" variant={isSelected ? "solid" : "soft"}>								3							</Badge>						</>					)}				</Tabs.Trigger>				<Tabs.Trigger testID="composed-profile" value="profile">					<Icon icon={IconUser} />					<Tabs.Label>Profile</Tabs.Label>				</Tabs.Trigger>			</Tabs.List>			<Tabs.Content value="alerts">				<View className="h-20 justify-center rounded-lg bg-secondary px-4">					<Text.Paragraph>Three unread alerts.</Text.Paragraph>				</View>			</Tabs.Content>			<Tabs.Content value="profile">				<View className="h-20 justify-center rounded-lg bg-secondary px-4">					<Text.Paragraph>Your profile.</Text.Paragraph>				</View>			</Tabs.Content>		</Tabs>	);}

The label's colour crossfades, off the same position everything else reads, so it fades with the capsule arriving rather than flipping at a midpoint. That means it is a style, not a class — the label slot names no colour at all, and a test enforces it.

Selection changes the label's colour and nothing else. A weight change would re-measure the label, which moves the frame the indicator is sitting on, on every tap.

A composed Icon takes its colour as a resolved value, so it cannot be half way between two — it swaps at the midpoint with a hysteresis band. On a trigger holding both a glyph and a label the glyph steps while the text fades.

Separators

Separators
Separators
import { Tabs } from "@delacour/native-ui/tabs";import { Text } from "@delacour/native-ui/text";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<Tabs variant="secondary">			<Tabs.List>				<Tabs.Indicator />				<Tabs.Trigger testID="sep-overview" value="overview">					Overview				</Tabs.Trigger>				<Tabs.Separator betweenValues={["overview", "activity"]} />				<Tabs.Trigger testID="sep-activity" value="activity">					Activity				</Tabs.Trigger>				<Tabs.Separator betweenValues={["activity", "settings"]} />				<Tabs.Trigger testID="sep-settings" value="settings">					Settings				</Tabs.Trigger>			</Tabs.List>			<Tabs.Content value="overview">				<View className="h-20 justify-center rounded-lg bg-secondary px-4">					<Text.Paragraph>Everything at a glance.</Text.Paragraph>				</View>			</Tabs.Content>			<Tabs.Content value="activity">				<View className="h-20 justify-center rounded-lg bg-secondary px-4">					<Text.Paragraph>What changed, and when.</Text.Paragraph>				</View>			</Tabs.Content>			<Tabs.Content value="settings">				<View className="h-20 justify-center rounded-lg bg-secondary px-4">					<Text.Paragraph>Nothing here is saved.</Text.Paragraph>				</View>			</Tabs.Content>		</Tabs>	);}

A separator fades only while the pager is crossing it. At rest the bar shows every rule it has; a drag dips the one it travels over and brings it back.

A faded separator still takes its width, so the row never reflows. That also means separators make a bar wider — enough to tip a row that only just fits into one that scrolls. Reach for Tabs.ScrollView when the tabs genuinely do not fit, not by default.

The elevated token

The capsule is painted on elevated, a theme token minted for it. The surface has to sit above muted in both themes and nothing in the neutral ramp did: card is the same white as background in light and darker than muted in dark, so a capsule painted on it reads as raised in one theme and sunken in the other.

Deliberate omissions

No Tabs.ListBackground and no background prop — bg-muted on the list slot is the whole feature, and variant already says whether there is one. No public pager, no orientation (a vertical tab bar is a sidebar, a different component), and no router prop: onValueChange is where your app calls its router.

There is no Field rung on the axis ladder and no isInvalid. Tabs are navigation — nothing here has a value that can be wrong, and a Field around a Tabs would grey out a navigation bar from a form's state.

API

Prop

Type

On this page