AniUI

Segmented Control

iOS-style segmented control for switching between views or filter options. Generic over the value type — binds cleanly to enums and string unions.

Showing List view

Web preview — components render natively on iOS & Android
import { SegmentedControl } from "@/components/ui/segmented-control";

const [view, setView] = useState("List");
<SegmentedControl options={["List", "Grid", "Map"]} value={view} onValueChange={setView} />

Installation#

npx @aniui/cli add segmented-control

Sizes#

Small

Medium (default)

Large

Web preview — components render natively on iOS & Android
<SegmentedControl size="sm" options={["S", "M", "L"]} value={size} onValueChange={setSize} />
<SegmentedControl size="md" options={["S", "M", "L"]} value={size} onValueChange={setSize} />
<SegmentedControl size="lg" options={["S", "M", "L"]} value={size} onValueChange={setSize} />

Generic value type and i18n#

Bind to an enum or string union by passing the type parameter. Use the parallel labels array when you already have Object.values(MyEnum).

// SegmentedControl is generic over the value type, so it binds cleanly to enums or string unions.
enum Model { P0 = "p0", P1 = "p1", Litter = "litter", A3 = "a3" }
const [model, setModel] = useState<Model>(Model.P0);

<SegmentedControl<Model>
  options={Object.values(Model)}
  labels={[t("p0"), t("p1"), "猫砂盆", "A3"]}
  value={model}
  onValueChange={setModel}
/>

Or pass an array of objects so each value is paired with its label — safer for i18n since the two cannot drift out of order. The object form also accepts disabled per item.

// Pair each value with its label in one array — safest for i18n (no index-drift risk).
<SegmentedControl<Model>
  options={[
    { value: Model.P0,     label: t("p0") },
    { value: Model.P1,     label: t("p1"), disabled: true },
    { value: Model.Litter, label: "猫砂盆" },
    { value: Model.A3,     label: "A3" },
  ]}
  value={model}
  onValueChange={setModel}
/>

Props#

PropTypeDefault
options
T[] | { value: T; label?: string; disabled?: boolean }[]

Either a list of values, or a list of objects pairing each value with its label.

labels
string[]

Parallel labels array. Only used when options is T[]. Indices must align with options.

value
T

Currently selected value. Inferred from the options type.

onValueChange
(value: T) => void

Fires only when a different segment is tapped.

size
"sm" | "md" | "lg"
"md"
className
string

Also accepts all View props (testID, onLayout, accessibilityHint, style, etc.).

Accessibility#

  • Tab-like control with selected state announced to screen readers.
  • Each segment has accessibilityState for selected, unselected, and disabled.
  • Long labels are truncated to one line so layout stays stable across locales.

Source#

components/ui/segmented-control.tsx
import React from "react";
import { View, Pressable, Text, useColorScheme } from "react-native";
import { cn } from "@/lib/utils";

const heights = { sm: 36, md: 44, lg: 56 } as const;

export type SegmentedOption<T extends string | number> = {
  value: T;
  label?: string;
  disabled?: boolean;
};

export interface SegmentedControlProps<T extends string | number = string>
  extends Omit<React.ComponentProps<typeof View>, "children"> {
  className?: string;
  options: T[] | SegmentedOption<T>[];
  labels?: string[];
  value: T;
  onValueChange: (value: T) => void;
  size?: "sm" | "md" | "lg";
}

function isOptionObject<T extends string | number>(o: T | SegmentedOption<T>): o is SegmentedOption<T> {
  return typeof o === "object" && o !== null && "value" in (o as object);
}

export function SegmentedControl<T extends string | number = string>({
  size = "md", className, style, options, labels, value, onValueChange, ...rest
}: SegmentedControlProps<T>) {
  const dark = useColorScheme() === "dark";
  const activeBg = dark ? "#27272a" : "#ffffff";
  const activeFg = dark ? "#fafafa" : "#09090b";
  const inactiveFg = dark ? "#a1a1aa" : "#71717a";
  const disabledFg = dark ? "#52525b" : "#d4d4d8";

  const items: SegmentedOption<T>[] = options.map((o, i) =>
    isOptionObject(o)
      ? { value: o.value, label: o.label ?? String(o.value), disabled: o.disabled }
      : { value: o, label: labels?.[i] ?? String(o) }
  );

  return (
    <View
      className={cn("rounded-lg bg-muted", className)}
      style={[{ height: heights[size], padding: 4, flexDirection: "row", borderRadius: 8 }, style]}
      accessibilityRole="tablist"
      {...rest}
    >
      {items.map(({ value: v, label, disabled }) => {
        const active = v === value;
        return (
          <Pressable
            key={String(v)}
            disabled={disabled}
            style={{
              flex: 1, alignItems: "center", justifyContent: "center", borderRadius: 6,
              opacity: disabled ? 0.5 : 1,
              backgroundColor: active ? activeBg : "transparent",
              ...(active ? { shadowColor: "#000", shadowOpacity: dark ? 0.4 : 0.08, shadowRadius: 2, shadowOffset: { width: 0, height: 1 }, elevation: 1 } : {}),
            }}
            onPress={() => { if (!active) onValueChange(v); }}
            accessible={true}
            accessibilityRole="tab"
            accessibilityState={{ selected: active, disabled: !!disabled }}
          >
            <Text numberOfLines={1} style={{ fontSize: 14, fontWeight: "500", color: disabled ? disabledFg : active ? activeFg : inactiveFg }}>
              {label}
            </Text>
          </Pressable>
        );
      })}
    </View>
  );
}