The options
| Option | iOS | Android | Pick it for | The cost |
|---|---|---|---|---|
@react-native-segmented-control/segmented-control | Real UISegmentedControl | JS imitation of the iOS style | iOS-first apps that want the exact system control | Two different looks; limited styling on iOS |
Custom (Pressable + Reanimated) | Your design | Your design | Identical on both platforms, matches your theme | 60 lines you maintain; accessibility is on you |
react-native-paper SegmentedButtons | Material 3 outlined buttons | Material 3 outlined buttons | Apps already on Paper and Material 3 | Looks Android-flavoured on iOS |
react-native-ui-lib SegmentedControl | Themed pill control | Themed pill control | Apps already using ui-lib | Pulls in a large design system |
The honest default in 2026 is the custom control. The native package is excellent on iOS and merely fine on Android, and most apps today have a design system that neither platform’s stock control matches anyway. Use the native package when you are deliberately building an iOS-native-feeling app and Android is secondary.
Option 1: the native package
npx expo install @react-native-segmented-control/segmented-controlimport { useState } from 'react';
import SegmentedControl from '@react-native-segmented-control/segmented-control';
const RANGES = ['Week', 'Month', 'Year'] as const;
export function RangePicker({ onChange }: { onChange: (r: (typeof RANGES)[number]) => void }) {
const [index, setIndex] = useState(0);
return (
<SegmentedControl
values={[...RANGES]}
selectedIndex={index}
onChange={(e) => {
const i = e.nativeEvent.selectedSegmentIndex;
setIndex(i);
onChange(RANGES[i]);
}}
// iOS 13+ style on both platforms; omit for the older bordered iOS look
appearance="dark" // 'light' | 'dark'; defaults to the system
tintColor="#f97316" // the selected pill
fontStyle={{ color: '#aaa' }}
activeFontStyle={{ color: '#fff', fontWeight: '600' }}
style={{ height: 36 }}
/>
);
}Three props do the styling: tintColor for the selected pill, backgroundColor for the track, and the two fontStyle props for label colour and weight. That is roughly all UIKit allows, which is the point of a native control: it looks like the OS and you do not fight it. Set appearance explicitly if your app forces a theme, otherwise it follows the system and can mismatch a dark-only screen.
Two things to know. The onChange event wraps the index in nativeEvent.selectedSegmentIndex, with a convenience onValueChange that gives you the label string. And on Android and web the component is a JavaScript render of the iOS look, so the same props work but the result sits in a Material app like a visitor. If that bothers you, read on.
Option 2: a custom control with a sliding indicator
The whole thing is a row of Pressables over an absolutely-positioned pill whose left edge is animated. Measure the container once, divide by the number of segments, and move the pill with a spring:
import { useState } from 'react';
import { LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';
type Props<T extends string> = {
options: readonly T[];
value: T;
onChange: (v: T) => void;
};
const PAD = 3;
export function SegmentedControl<T extends string>({ options, value, onChange }: Props<T>) {
const [width, setWidth] = useState(0);
const segment = width > 0 ? (width - PAD * 2) / options.length : 0;
const index = Math.max(0, options.indexOf(value));
const x = useSharedValue(0);
// Animate the pill whenever the selected index or the measured width changes.
x.value = withSpring(index * segment, { damping: 18, stiffness: 220, mass: 0.6 });
const pillStyle = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
return (
<View style={styles.track} onLayout={onLayout} accessibilityRole="tablist">
{segment > 0 && (
<Animated.View style={[styles.pill, { width: segment }, pillStyle]} pointerEvents="none" />
)}
{options.map((opt) => {
const selected = opt === value;
return (
<Pressable
key={opt}
style={styles.segment}
accessibilityRole="tab"
accessibilityState={{ selected }}
accessibilityLabel={opt}
onPress={() => {
if (selected) return;
Haptics.selectionAsync();
onChange(opt);
}}
>
<Text style={[styles.label, selected && styles.labelSelected]} numberOfLines={1}>
{opt}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
track: {
flexDirection: 'row',
height: 40,
padding: PAD,
borderRadius: 12,
backgroundColor: 'rgba(120,120,128,0.18)',
},
pill: {
position: 'absolute',
top: PAD,
bottom: PAD,
left: PAD,
borderRadius: 9,
backgroundColor: '#ffffff',
// Android needs elevation, iOS needs shadow*, set both.
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
segment: { flex: 1, alignItems: 'center', justifyContent: 'center' },
label: { fontSize: 14, fontWeight: '500', color: 'rgba(60,60,67,0.7)' },
labelSelected: { color: '#000', fontWeight: '600' },
});Usage is a controlled component like any other:
const [view, setView] = useState<'List' | 'Map'>('List');
<SegmentedControl options={['List', 'Map'] as const} value={view} onChange={setView} />
{view === 'List' ? <ResultsList /> : <ResultsMap />}The details that make it feel right: the pill is measured from the container rather than hard-coded, so it works at any width and with any number of segments. The spring is slightly underdamped so the pill settles with a hint of overshoot, which is what the iOS control does. And Haptics.selectionAsync() gives the tiny click that UIKit plays for free. Drop that line if you are not on Expo, or guard it with a platform check; it is a no-op on Android devices without a haptic engine.
Option 3: Paper SegmentedButtons for Material apps
If the app is built on react-native-paper, the Material 3 version is already installed:
import { SegmentedButtons } from 'react-native-paper';
<SegmentedButtons
value={view}
onValueChange={setView}
buttons={[
{ value: 'list', label: 'List', icon: 'format-list-bulleted' },
{ value: 'map', label: 'Map', icon: 'map' },
]}
/>It supports icons, a multiSelect mode, and the Paper theme. It also looks unmistakably Material, outlined buttons joined at the edges rather than a pill in a track, which is right on Android and slightly foreign on iOS. That is the trade: platform-correct on one side, or identical on both.
Four things that get skipped
- Accessibility roles. Three bare Pressables announce as three unrelated buttons. The
tablistandtabroles withaccessibilityState.selectedmake VoiceOver say “Month, tab, 2 of 3, selected.” The native package does this for you; the custom one above does it explicitly. - Long labels. Segments are equal width, so one long label truncates while its neighbours have room. Keep labels to one or two words, or let the control scroll horizontally, at which point it has become a chip row.
- Do not drive navigation with it. If each segment is a route with its own back stack, that is a tab bar or a top tab navigator. A segmented control that pushes screens confuses the back button on Android. The tab bar guide covers that case.
- Keep the content swap cheap. Switching List to Map should not unmount and rebuild a map view every time. Keep both mounted and toggle visibility, or at minimum memoise the heavy child, so the control feels instant rather than tied to a loading state.
Skip the wiring
A measured track, a spring-animated pill, proper tab roles, a haptic tick. Sixty lines, written once per app and slightly differently each time. ShipNative generates a real Expo app from a description with components like this already in the design system, previews it on your phone, and gives you the full React Native project to keep. Describe the screen with its List and Map toggle, then edit the control like any file you wrote.