Free referenceMehdi Davoodi
React Native Switch Selector
A React Native switch selector is a segmented control with a pill that slides behind the active label, and you can build one with core React Native plus a single Reanimated shared value driving translateX off the measured container width.
Installation
The only dependency is Reanimated, which since SDK 54 ships its worklets runtime as a second package. Everything else is core React Native.
npx expo install react-native-reanimated react-native-worklets
Usage
import { useCallback, useEffect, useState } from 'react';
import {
type LayoutChangeEvent,
Pressable,
StyleSheet,
View,
} from 'react-native';
import Animated, {
interpolateColor,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
const TRACK_PADDING = 4;
const ACTIVE_LABEL = '#111111';
const INACTIVE_LABEL = '#8A8A8E';
const SPRING = { damping: 18, stiffness: 220, mass: 0.7 };
type SegmentProps = {
label: string;
index: number;
selected: boolean;
progress: SharedValue<number>;
onPress: (index: number) => void;
};
function Segment({ label, index, selected, progress, onPress }: SegmentProps) {
const textStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[index - 1, index, index + 1],
[INACTIVE_LABEL, ACTIVE_LABEL, INACTIVE_LABEL],
),
}));
return (
<Pressable
style={styles.segment}
onPress={() => onPress(index)}
accessibilityRole="tab"
accessibilityLabel={label}
accessibilityState={{ selected }}
>
<Animated.Text
style={[styles.label, textStyle]}
numberOfLines={1}
maxFontSizeMultiplier={1.4}
>
{label}
</Animated.Text>
</Pressable>
);
}
type SwitchSelectorProps = {
options: string[];
value: number;
onChange: (index: number) => void;
};
export function SwitchSelector({ options, value, onChange }: SwitchSelectorProps) {
const [trackWidth, setTrackWidth] = useState(0);
const progress = useSharedValue(value);
const segmentWidth =
trackWidth > 0 ? (trackWidth - TRACK_PADDING * 2) / options.length : 0;
useEffect(() => {
progress.value = withSpring(value, SPRING);
}, [value, progress]);
const onTrackLayout = useCallback((event: LayoutChangeEvent) => {
setTrackWidth(event.nativeEvent.layout.width);
}, []);
const pillStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.value * segmentWidth }],
}));
return (
<View style={styles.track} onLayout={onTrackLayout} accessibilityRole="tablist">
{segmentWidth > 0 ? (
<Animated.View
pointerEvents="none"
style={[styles.pill, { width: segmentWidth }, pillStyle]}
/>
) : null}
{options.map((option, index) => (
<Segment
key={option}
label={option}
index={index}
selected={index === value}
progress={progress}
onPress={onChange}
/>
))}
</View>
);
}
export default function SwitchSelectorDemo() {
const [value, setValue] = useState(0);
return (
<View style={styles.screen}>
<SwitchSelector
options={['Daily', 'Weekly', 'Monthly']}
value={value}
onChange={setValue}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: 'center',
padding: 24,
backgroundColor: '#FFFFFF',
},
track: {
flexDirection: 'row',
padding: TRACK_PADDING,
borderRadius: 999,
backgroundColor: '#F2F2F7',
},
pill: {
position: 'absolute',
top: TRACK_PADDING,
bottom: TRACK_PADDING,
left: TRACK_PADDING,
borderRadius: 999,
backgroundColor: '#FFFFFF',
shadowColor: '#000000',
shadowOpacity: 0.12,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
segment: {
flex: 1,
elevation: 3,
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
},
label: {
fontSize: 15,
fontWeight: '600',
},
});
How it works
The pill is one absolutely positioned view sitting behind the labels. It is
one segment wide and sits at that width times the index, so both numbers only
exist after layout. The track measures itself with onLayout and
derives (trackWidth - padding * 2) / options.length. Width then
stays a plain style and only translateX is animated, so moving
the pill never runs layout again.
Animation lives in a shared value rather than in state. The parent owns
value, and a useEffect springs
progress toward it, so the pill moves the same way whether the
user tapped a segment or the app set the index. The spring runs on the UI
thread, so it keeps moving while JavaScript renders the new screen.
Because progress is a continuous number, the labels react to it
mid-flight. Each interpolates its color across
[index - 1, index, index + 1]: dim while the pill is a segment
away, mid tone as it passes under, fully dark once it settles.
Gotchas
On Android, elevation decides draw order
elevation is not only a shadow: Android sorts siblings by it. An
opaque pill with elevation: 2 paints over every sibling that has
none, so the active label disappears while taps keep working. Give the
segments a higher elevation than the pill, as above, or drop elevation and
keep the shadow on iOS only.
The first frame has no width
onLayout fires after the first render, so on frame one
segmentWidth is zero and a pill rendered anyway is a sliver that
pops to full size. Gate it on segmentWidth > 0, and seed
progress with the current index so the pill starts in place.
Font scaling breaks equal thirds
Equal-width segments assume the labels fit. With iOS Dynamic Type or Android
font scale at maximum, "Monthly" wraps or truncates while the pill stays the
same size. Cap the growth with maxFontSizeMultiplier and keep
numberOfLines={1}.
Screen readers need the roles
Without roles a segmented control reads as a row of unrelated text. Mark the
track tablist and each option tab with
accessibilityState={{ selected }}, so VoiceOver and TalkBack
announce "selected" and the position in the group. Keep segments at least 44
points tall for the touch target.
Is a switch selector the same as React Native's Switch component?
No. The Switch exported by React Native is a boolean toggle, on
or off, rendered by the platform. A switch selector, also called a segmented
control, shows two or more labeled options side by side and returns which one
is active. Use Switch for a setting that is on or off, and a
segmented selector for choosing one option out of a small fixed set.
Do I need a library to build a segmented control in React Native?
No. A segmented control is core React Native plus Reanimated, which most Expo
projects already have. A dedicated package makes sense only if you need the
exact native iOS look of UISegmentedControl, which
@react-native-segmented-control/segmented-control wraps. The
trade-off is that the native control cannot be restyled much and looks
different on Android.
How do I let users drag the pill instead of tapping?
Add a Gesture.Pan() from react-native-gesture-handler to the
track. In onUpdate set progress.value to the finger
position divided by the segment width, clamped to zero through
options.length - 1. In onEnd round to the nearest
index, spring to it, and call back with runOnJS so the parent
state matches. The labels keep interpolating during the drag, since they read
the same shared value.
Does this work with more than three options?
Yes. The math is generic, so any number of options divides the track evenly.
In practice five is about the limit before labels get too narrow to read on a
small phone. Past that, put the track in a horizontal
ScrollView, give each segment a fixed width instead of
flex: 1, and scroll the selected one into view.
Depends onreact-native-reanimatedreact-native-worklets
Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.