Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Segmented Control
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsSelectionSegmented Control

Segmented Control

Scan with Expo Go

Segmented Control with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

A liquid indicator that stretches as it slides; drag it to scrub.

<SegmentedControl />6 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
options

Segments. `icon` can be a node or ({ active, color }) => node.

(string | { value, label?, icon?, disabled? })[]—
value / onValueChange

Controlled selection (or use defaultValue; defaults to the first segment).

string—
defaultValue

Initial selection when uncontrolled.

stringfirst segment
variant

Raised pill on a track, tab underline, or tinted outline.

"pill" | "underline" | "outline""pill"
size

32 / 40 / 48pt tall.

"sm" | "md" | "lg""md"
fullWidth

Equal-width segments filling the row, or segments sized to their labels.

booleantrue
accent

Indicator colour (pill becomes a filled accent pill).

stringtheme accent
haptics

Selection tick on change.

booleantrue
disabled

Dims and blocks presses.

booleanfalse
style

Style for the track holding the segments.

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
The leading edge of the indicator springs ahead and the trailing edge catches up, so it stretches as it travels. Labels squish on press and bounce when selected.
Touch
A sideways drag that starts on the selected segment scrubs across the others; drags that start elsewhere are left to a parent ScrollView. Short segments get hitSlop up to 44pt.
Haptics
A selection tick plays on each change (haptics, on by default).
Screen readers
A tablist of tabs with selected and disabled state, labelled by each option's label or accessibilityLabel.

Source#

PreviousRatingNextSwitch

Did you like the content?

Coding agents

Chat apps

Other

9:41

Pill

Segmented Control

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/segmented-control.tsx342 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { SegmentedControl } from "@/components/ui/segmented-control";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { SegmentedControl } from '@/components/ui/segmented-control'; const [range, setRange] = useState('Week');<SegmentedControl options={['Day', 'Week', 'Month', 'Year']} value={range} onValueChange={setRange} />

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/segmented-control.tsx
import * as Haptics from 'expo-haptics';import React, { useEffect, useId, useMemo, useRef, useState } from 'react';import {  Animated,  PanResponder,  Pressable,  StyleSheet,  View,  type GestureResponderEvent,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; export type SegmentedControlVariant = 'pill' | 'underline' | 'outline';export type SegmentedControlSize = 'sm' | 'md' | 'lg'; export interface SegmentOption {  value: string;  label?: string;  /** Icon node, or a function that receives the current text colour. */  icon?: React.ReactNode | ((state: { active: boolean; color: string }) => React.ReactNode);  accessibilityLabel?: string;  disabled?: boolean;} export interface SegmentedControlProps {  /** Segments — plain strings or `{ value, label, icon }` objects. */  options: (string | SegmentOption)[];  /** Controlled selection. */  value?: string;  /** Initial selection when uncontrolled (default: first segment). */  defaultValue?: string;  onValueChange?: (value: string) => void;  variant?: SegmentedControlVariant;  size?: SegmentedControlSize;  /** Equal-width segments that fill the row (default) or segments sized to their content. */  fullWidth?: boolean;  /** Indicator colour. Pill uses a raised surface unless an accent is given. */  accent?: string;  disabled?: boolean;  /** Selection tick on change (default true). */  haptics?: boolean;  accessibilityLabel?: string;  style?: StyleProp<ViewStyle>;} const SIZES: Record<SegmentedControlSize, { h: number; font: number; px: number; icon: number }> = {  sm: { h: 32, font: 13, px: 12, icon: 14 },  md: { h: 40, font: 14.5, px: 16, icon: 16 },  lg: { h: 48, font: 16, px: 20, icon: 18 },}; const INSET = 3;// Liquid move: the edge in the direction of travel leads, the other one trails behind.const LEAD = motion.snappy;const TRAIL = { damping: 20, stiffness: 150, mass: 0.9 }; /** * Segmented control with a liquid indicator: its leading edge springs ahead and the trailing * edge catches up, so it stretches as it travels. Measured with onLayout so it fits any label. * Drag the selected segment to scrub across the others. Pill (iOS), underline (tabs) and outline * looks, with optional icons. */export function SegmentedControl({  options,  value,  defaultValue,  onValueChange,  variant = 'pill',  size = 'md',  fullWidth = true,  accent,  disabled,  haptics = true,  accessibilityLabel,  style,}: SegmentedControlProps) {  const { colors, radius, dark } = useKit();  const gid = 'sc' + useId().replace(/[^a-zA-Z0-9]/g, '');  const items: SegmentOption[] = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));  const [inner, setInner] = useState(defaultValue ?? items[0]?.value);  const selected = value ?? inner;  const index = Math.max(0, items.findIndex((o) => o.value === selected));  const s = SIZES[size];   const layouts = useRef<{ x: number; width: number }[]>([]);  const [measured, setMeasured] = useState(0);  // Left and right edges of the indicator, animated separately (JS driver, they set left/width).  const lx = useRef(new Animated.Value(0)).current;  const rx = useRef(new Animated.Value(0)).current;  const placed = useRef(false);  const shown = useRef(-1);   useEffect(() => {    const l = layouts.current[index];    if (!l) return;    if (!placed.current) {      lx.setValue(l.x);      rx.setValue(l.x + l.width);      placed.current = true;      shown.current = index;      return;    }    const right = index >= shown.current;    shown.current = index;    Animated.parallel([      Animated.spring(lx, { toValue: l.x, useNativeDriver: false, ...(right ? TRAIL : LEAD) }),      Animated.spring(rx, { toValue: l.x + l.width, useNativeDriver: false, ...(right ? LEAD : TRAIL) }),    ]).start();  }, [index, measured, lx, rx]);   const onSegmentLayout = (i: number) => (e: LayoutChangeEvent) => {    const { x: lx, width } = e.nativeEvent.layout;    const prev = layouts.current[i];    if (prev && prev.x === lx && prev.width === width) return;    layouts.current[i] = { x: lx, width };    setMeasured((n) => n + 1);  };   const select = (next: string) => {    if (next === selected) return;    if (haptics) Haptics.selectionAsync().catch(() => {});    if (value === undefined) setInner(next);    onValueChange?.(next);  };   // Scrub: a horizontal drag that starts on the selected segment slides the selection along.  // Drags that start elsewhere are left alone, so the control still scrolls inside a ScrollView.  const live = useRef({ items, select, disabled });  live.current = { items, select, disabled };  const touch = useRef({ originX: 0, onActive: false });  const onSegmentPressIn = (i: number, active: boolean) => (e: GestureResponderEvent) => {    const l = layouts.current[i];    touch.current = { originX: e.nativeEvent.pageX - ((l?.x ?? 0) + e.nativeEvent.locationX), onActive: active };  };  const scrub = useMemo(    () =>      PanResponder.create({        onMoveShouldSetPanResponderCapture: (_, g) =>          !live.current.disabled && touch.current.onActive && Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,        onPanResponderTerminationRequest: () => false,        onPanResponderRelease: () => (touch.current.onActive = false),        onPanResponderTerminate: () => (touch.current.onActive = false),        onPanResponderMove: (_, g) => {          const at = g.moveX - touch.current.originX;          const { items: opts, select: pick } = live.current;          let best = -1;          let dist = Infinity;          layouts.current.forEach((l, i) => {            if (!l || opts[i]?.disabled) return;            const d = at < l.x ? l.x - at : at > l.x + l.width ? at - l.x - l.width : 0;            if (d < dist) [best, dist] = [i, d];          });          if (best >= 0) pick(opts[best].value);        },      }),    []  );   const tint = accent ?? colors.accent;  const pill = variant === 'pill';  const underline = variant === 'underline';  const outline = variant === 'outline';  const segRadius = Math.min(radius.md, (s.h - INSET * 2) / 2);   const activeFg = pill ? (accent ? readableOn(accent) : colors.foreground) : underline ? colors.foreground : tint;  const idleFg = colors.mutedForeground;   const indicator: ViewStyle = pill    ? {        top: INSET,        bottom: INSET,        borderRadius: segRadius,        backgroundColor: accent ?? (dark ? colors.input : colors.background),        borderWidth: accent ? 0 : StyleSheet.hairlineWidth,        borderColor: alpha(colors.foreground, dark ? 0.1 : 0.06),      }    : underline      ? { bottom: 0, height: 3, borderTopLeftRadius: 3, borderTopRightRadius: 3, backgroundColor: tint }      : {          top: INSET,          bottom: INSET,          borderRadius: segRadius,          backgroundColor: alpha(tint, 0.12),          borderWidth: 1.5,          borderColor: alpha(tint, 0.55),        };   // onLayout x includes the track border; absolute children start inside it.  const trackBorder = pill ? StyleSheet.hairlineWidth : outline ? 1 : 0;   return (    <View      {...scrub.panHandlers}      accessibilityRole="tablist"      accessibilityLabel={accessibilityLabel}      style={[        styles.track,        { opacity: disabled ? 0.5 : 1, alignSelf: fullWidth ? 'stretch' : 'flex-start' },        pill && {          padding: INSET,          borderRadius: segRadius + INSET,          backgroundColor: colors.muted,          borderWidth: trackBorder,          borderColor: colors.border,        },        outline && { padding: INSET, borderRadius: segRadius + INSET, borderWidth: 1, borderColor: colors.border },        underline && { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: colors.border },        style,      ]}    >      <Animated.View        pointerEvents="none"        style={[          styles.indicator,          indicator,          pill && !accent && styles.shadow,          {            left: trackBorder ? Animated.add(lx, -trackBorder) : lx,            width: Animated.subtract(rx, lx),            opacity: layouts.current[index] ? 1 : 0,          },        ]}      >        {pill && accent ? (          // A soft sheen on the filled accent pill.          <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>            <Defs>              <LinearGradient id={gid} x1="0" y1="0" x2="0" y2="1">                <Stop offset="0" stopColor="#ffffff" stopOpacity={0.26} />                <Stop offset="0.6" stopColor="#ffffff" stopOpacity={0} />              </LinearGradient>            </Defs>            <Rect width="100%" height="100%" rx={segRadius} fill={`url(#${gid})`} />          </Svg>        ) : null}      </Animated.View>      {items.map((o, i) => (        <Segment          key={o.value}          option={o}          active={i === index}          onPress={() => select(o.value)}          onPressIn={onSegmentPressIn(i, i === index)}          onLayout={onSegmentLayout(i)}          fullWidth={fullWidth}          size={s}          height={underline ? s.h : s.h - INSET * 2}          activeFg={activeFg}          idleFg={idleFg}          disabled={disabled || o.disabled}        />      ))}    </View>  );} interface SegmentProps {  option: SegmentOption;  active: boolean;  onPress: () => void;  onPressIn: (e: GestureResponderEvent) => void;  onLayout: (e: LayoutChangeEvent) => void;  fullWidth: boolean;  size: { h: number; font: number; px: number; icon: number };  height: number;  activeFg: string;  idleFg: string;  disabled?: boolean;} function Segment({ option, active, onPress, onPressIn, onLayout, fullWidth, size, height, activeFg, idleFg, disabled }: SegmentProps) {  // a: text colour (JS). press: content squish, pop: little bounce on selection (native, same view).  const a = useRef(new Animated.Value(active ? 1 : 0)).current;  const press = useRef(new Animated.Value(1)).current;  const pop = useRef(new Animated.Value(1)).current;  const mounted = useRef(false);   useEffect(() => {    Animated.timing(a, { toValue: active ? 1 : 0, duration: 180, useNativeDriver: false }).start();    if (active && mounted.current) {      pop.setValue(0.9);      Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();    }    mounted.current = true;  }, [active, a, pop]);   const pressTo = (to: number) =>    Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();   const color = a.interpolate({ inputRange: [0, 1], outputRange: [idleFg, activeFg] });  const icon =    typeof option.icon === 'function' ? option.icon({ active, color: active ? activeFg : idleFg }) : option.icon;   return (    <Pressable      accessibilityRole="tab"      accessibilityState={{ selected: active, disabled: !!disabled }}      accessibilityLabel={option.accessibilityLabel ?? option.label}      disabled={disabled}      onPress={onPress}      onPressIn={(e) => {        onPressIn(e);        pressTo(0.94);      }}      onPressOut={() => pressTo(1)}      onLayout={onLayout}      hitSlop={height < 44 ? { top: (44 - height) / 2, bottom: (44 - height) / 2 } : undefined}      style={[styles.segment, fullWidth && styles.flex, { height, paddingHorizontal: size.px }]}    >      <Animated.View style={[styles.content, { transform: [{ scale: Animated.multiply(press, pop) }] }]}>        {icon ? <View style={{ minWidth: size.icon, alignItems: 'center' }}>{icon}</View> : null}        {option.label ? (          <Animated.Text numberOfLines={1} style={[styles.label, { fontSize: size.font, color }]}>            {option.label}          </Animated.Text>        ) : null}      </Animated.View>    </Pressable>  );} const styles = StyleSheet.create({  track: { flexDirection: 'row', alignItems: 'center' },  flex: { flex: 1 },  indicator: { position: 'absolute' },  shadow: {    shadowColor: '#000',    shadowOpacity: 0.1,    shadowRadius: 8,    shadowOffset: { width: 0, height: 2 },    elevation: 2,  },  segment: { alignItems: 'center', justifyContent: 'center', minWidth: 0 },  content: { flexDirection: 'row', alignItems: 'center', gap: 6 },  label: { fontWeight: '400', letterSpacing: -0.1, userSelect: 'none' },});