Skip to content
Native Animation

React Native Segmented Control with Reanimated

By Native Animation · Updated

A React Native segmented control selects one of a few related views or settings. This tutorial builds a controlled three-segment input with unequal label widths, a measured sliding indicator, tap selection, and optional dragging. The parent owns the accepted value; the animation follows it without setting React state on every frame.

For a standard platform control, start with the Expo SDK 58 segmented control. Use a custom implementation when measured geometry and shared cross-platform styling are requirements. For a longer list of form values, use a dropdown select instead. For visible form choices with longer descriptions, use a radio group. For actual navigation between routes, keep animated tabs connected to your router.

Setup

The example targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3. Install compatible packages with Expo:

npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler

Use bunx expo install in Bun projects. Follow the SDK 58 Reanimated setup. Keep the gesture inside a GestureHandlerRootView; reuse your application's root if one already exists. Save the following complete file as SegmentedControl.tsx outside the Expo Router route directory, then render SegmentedExample from a screen.

Build the controlled input

The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.

import { useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
  cancelAnimation, Extrapolation, interpolate, useAnimatedStyle,
  useReducedMotion, useSharedValue, withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

type Segment = { id: string; label: string };
type Rect = { x: number; width: number };
const segments: Segment[] = [
  { id: 'day', label: 'Day' },
  { id: 'week', label: 'This week' },
  { id: 'month', label: 'Entire month' },
];

export function SegmentedControl({ value, onChange }: {
  value: string;
  onChange: (id: string) => void;
}) {
  const index = segments.findIndex((item) => item.id === value);
  const [viewportWidth, setViewportWidth] = useState(0);
  const [rects, setRects] = useState<Record<string, Rect>>({});
  const progress = useSharedValue(Math.max(0, index));
  const origin = useSharedValue(0);
  const reducedMotion = useReducedMotion();
  const ready = segments.every((item) => rects[item.id]);
  const positions = segments.map((item) => rects[item.id]?.x ?? 0);
  const widths = segments.map((item) => rects[item.id]?.width ?? 0);
  const centers = positions.map((x, i) => x + widths[i] / 2);
  const indexes = segments.map((_, i) => i);

  useEffect(() => {
    progress.set(reducedMotion ? Math.max(0, index) : withSpring(Math.max(0, index), {
      damping: 24, stiffness: 260, mass: 1,
    }));
  }, [index, progress, reducedMotion]);

  function request(next: number) {
    // Restore the accepted value even if the parent rejects this request.
    progress.set(reducedMotion ? Math.max(0, index) : withSpring(Math.max(0, index)));
    onChange(segments[next].id);
  }

  const pan = Gesture.Pan()
    .enabled(ready && index >= 0 && positions[2] + widths[2] <= viewportWidth)
    .activeOffsetX([-8, 8])
    .failOffsetY([-16, 16])
    .onStart(() => {
      cancelAnimation(progress);
      origin.set(interpolate(progress.get(), indexes, centers, Extrapolation.CLAMP));
    })
    .onUpdate((event) => {
      progress.set(interpolate(origin.get() + event.translationX,
        centers, indexes, Extrapolation.CLAMP));
    })
    .onEnd(() => scheduleOnRN(request, Math.round(progress.get())))
    .onFinalize((_event, success) => {
      if (!success) progress.set(reducedMotion ? Math.max(0, index) : withSpring(Math.max(0, index)));
    });

  const indicator = useAnimatedStyle(() => ({
    opacity: ready && index >= 0 ? 1 : 0,
    left: interpolate(progress.get(), indexes, positions, Extrapolation.CLAMP),
    width: interpolate(progress.get(), indexes, widths, Extrapolation.CLAMP),
  }));

  return (
    <View>
      <Text accessibilityRole="header">Reporting period</Text>
      <ScrollView horizontal onLayout={({ nativeEvent }) => setViewportWidth(nativeEvent.layout.width)}
        contentContainerStyle={{ paddingVertical: 8 }}>
        <GestureDetector gesture={pan} touchAction="pan-y">
          <Animated.View accessibilityRole="tablist" style={styles.track}>
            <Animated.View pointerEvents="none" style={[styles.indicator, indicator]} />
            {segments.map((item) => (
              <Pressable key={item.id} accessibilityRole="tab"
                accessibilityLabel={item.label + ' reporting period'}
                accessibilityState={{ selected: value === item.id }}
                onPress={() => request(segments.indexOf(item))}
                onLayout={({ nativeEvent: { layout } }) => {
                  const { x, width } = layout;
                  setRects((previous) => ({ ...previous, [item.id]: { x, width } }));
                }}
                style={styles.segment}>
                <Text style={styles.label}>{item.label}</Text>
              </Pressable>
            ))}
          </Animated.View>
        </GestureDetector>
      </ScrollView>
      {index < 0 && <Text accessibilityRole="alert">Choose an available period.</Text>}
    </View>
  );
}

const styles = StyleSheet.create({
  track: { flexDirection: 'row', backgroundColor: '#E8E4F0', borderRadius: 16, direction: 'ltr' },
  indicator: { position: 'absolute', top: 0, bottom: 0, backgroundColor: '#C8B6ED', borderRadius: 16 },
  segment: { minHeight: 48, minWidth: 48, paddingHorizontal: 18, paddingVertical: 12,
    justifyContent: 'center', alignItems: 'center' },
  label: { color: '#24163D', fontSize: 16 },
});

export function SegmentedExample() {
  const [period, setPeriod] = useState('week');
  return (
    <GestureHandlerRootView style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <SegmentedControl value={period} onChange={setPeriod} />
      <Text>Current period: {period}</Text>
      <Pressable accessibilityRole="button" accessibilityLabel="Reset reporting period to day"
        style={styles.segment} onPress={() => setPeriod('day')}><Text>Reset to day</Text></Pressable>
    </GestureHandlerRootView>
  );
}

Measure labels rather than dividing the screen

Each Pressable reports its x position and width inside the track. Horizontal padding and the rendered text determine its width; the indicator interpolates those measured bounds. Repeated layout events after a font loads or the text size changes replace the measurements. The indicator stays hidden until every label has reported its bounds. The example deliberately fixes three stable options at module scope; if you replace them with dynamic data, also invalidate old measurements and cancel active gestures when that data changes.

The pale indicator preserves dark-text contrast both inside and outside the selection. A selected accessibility state supplies meaning beyond color. Each target is at least forty-eight points in both dimensions, and labels can increase the height under larger text settings. Horizontal scrolling keeps long labels reachable. When the track exceeds the measured viewport, selection dragging is disabled so the scroll gesture can reveal the other segments; taps still select them.

Accept external values and rejected requests

The value is an option ID, not a label or an animation index. The Reset button demonstrates an external update. A value change retargets the spring from its current position. Tapping during a spring also retargets it, and starting a pan cancels the spring before recording its current position.

The request function restores the parent's accepted value before requesting another one. A parent that ignores the callback therefore leaves the old selection intact. An unknown value shows an explicit prompt with no selected indicator; the first segment does not silently become the accepted form value. A parent can immediately accept a local choice and persist it separately when network storage is involved.

During a pan, only shared values change. The scheduleOnRN function sends one requested selection to React on release. Cancellation returns to the accepted index. The sample lets an external update interrupt an in-progress drag; if that is inappropriate for your domain, disable input while replacing the underlying data.

Accessibility and gesture boundaries

Tabs expose labels and selected states using React Native accessibility properties. Pressable activation provides the touch, keyboard Enter/Space on web, and screen-reader path without requiring a drag. This compact tutorial leaves web Tab navigation on the individual segments; a full web tab widget should also implement arrow-key focus movement and connect tab IDs to their corresponding panels.

Horizontal activation and vertical failure thresholds leave vertical screen scrolling available before the pan activates. The geometry is explicitly left-to-right. Mirror layout and gesture mapping together for RTL. Reduced Motion updates the indicator immediately while keeping direct touch tracking.

Check all labels at large text sizes, a narrow card, font loading, rapid alternating taps, canceled pans, parent rejection, an invalid value, and external updates during a spring. Typechecking does not establish native gesture arbitration or screen-reader output; check VoiceOver and TalkBack on devices before shipping.

What the free component adds

Sliding Segmented Control adds a clipped high-contrast active-label layer, indicator stretch, layered light, a selection pop and sweep, and velocity-biased drag selection. Its API supports supplied segments, controlled or uncontrolled indexes, theme colors, sizing, fonts, spring tuning, and select/next/previous ref methods. The layered labels keep their contrast while the indicator passes beneath them.

The piece is free and uses Expo Linear Gradient as well as the gesture and animation dependencies. Read the README for the current API rather than assuming the tutorial's string IDs are the component's numeric value prop.

FAQ

When should I use a segmented control instead of a dropdown?

Use segments when a few short choices should stay visible together. A dropdown saves space for longer form labels or more options. Neither control should stand in for an action menu that runs commands.

Can the parent reject a change?

Yes. The parent supplies the accepted value, and onChange requests a new ID. Ignoring that request restores the old indicator position. External updates also move the indicator.

Do unequal labels require fixed widths?

No. Layout measurements provide each segment's real x position and width. Padding and text size determine the target bounds rather than a fixed fraction of screen width.