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.
$3,140
Total revenue this week
import { SegmentedControl } from "@/components/ui/segmented-control";
const [range, setRange] = useState("Week");
<SegmentedControl options={["Day", "Week", "Month"]} value={range} onValueChange={setRange} />Installation#
npx @aniui/cli add segmented-controlSizes#
Small
Medium (default)
Large
<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#
optionsT[] | { value: T; label?: string; disabled?: boolean }[]—Either a list of values, or a list of objects pairing each value with its label.
labelsstring[]—Parallel labels array. Only used when options is T[]. Indices must align with options.
valueT—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"classNamestring—Also accepts all View props (testID, onLayout, accessibilityHint, style, etc.).
Accessibility#
- Tab-like control with selected state announced to screen readers.
- Each segment has
accessibilityStatefor selected, unselected, and disabled. - Long labels are truncated to one line so layout stays stable across locales.
Source#
import React from "react";
import { View, Pressable, Text, useColorScheme } from "react-native";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
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 colors = useThemeColors();
const activeBg = dark ? "#37373a" : "#ffffff";
const activeFg = colors.foreground;
const inactiveFg = colors.mutedForeground;
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>
);
}

