Skip to content
All components

Free reference

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.