Phone Input
Phone number input with country code selector.
Installation#
npx @aniui/cli add phone-inputWeb preview — components render natively on iOS & Android
import { PhoneInput } from "@/components/ui/phone-input";
export function MyScreen() {
return (
<PhoneInput
defaultCountry="US"
onChangeText={(phone, dial, raw) => {
console.log(phone); // "+15551234567"
console.log(dial); // "+1"
console.log(raw); // "5551234567"
}}
/>
);
}Usage#
app/index.tsx
import { PhoneInput } from "@/components/ui/phone-input";
export function MyScreen() {
return (
<PhoneInput
defaultCountry="US"
onChangeText={(phone, dial, raw) => {
console.log(phone); // "+15551234567"
console.log(dial); // "+1"
console.log(raw); // "5551234567"
}}
/>
);
}Props#
PropTypeDefault
variant"default" | "ghost""default"size"sm" | "md" | "lg""md"defaultCountrystring"US"onChangeText(phone: string, dial: string, raw: string) => void-classNamestring-Also accepts all TextInput props except onChangeText.
Accessibility#
- Phone number input with country code picker.
- Country selector and input field are separately focusable for screen readers.
Source#
components/ui/phone-input.tsx
import React, { useState, useCallback } from "react";
import { View, TextInput, Pressable, Text, ScrollView, Modal, useColorScheme } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
import { ChevronDown } from "lucide-react-native";
const phoneVariants = cva("flex-row items-center rounded-md border", {
variants: {
variant: {
default: "border-input bg-background",
ghost: "border-transparent bg-transparent",
},
size: {
sm: "min-h-9 px-3",
md: "min-h-12 px-4",
lg: "min-h-14 px-5",
},
},
defaultVariants: { variant: "default", size: "md" },
});
type Country = { code: string; dial: string; name: string };
const countries: Country[] = [
{ code: "US", dial: "+1", name: "United States" },
{ code: "GB", dial: "+44", name: "United Kingdom" },
{ code: "IN", dial: "+91", name: "India" },
{ code: "CA", dial: "+1", name: "Canada" },
{ code: "AU", dial: "+61", name: "Australia" },
{ code: "DE", dial: "+49", name: "Germany" },
{ code: "FR", dial: "+33", name: "France" },
{ code: "JP", dial: "+81", name: "Japan" },
{ code: "BR", dial: "+55", name: "Brazil" },
{ code: "MX", dial: "+52", name: "Mexico" },
];
export interface PhoneInputProps
extends Omit<React.ComponentPropsWithoutRef<typeof TextInput>, "onChangeText" | "value">,
VariantProps<typeof phoneVariants> {
className?: string;
defaultCountry?: string;
value?: string;
onChangeText?: (fullPhone: string) => void;
}
export const PhoneInput = React.forwardRef<
React.ElementRef<typeof TextInput>,
PhoneInputProps
>(function PhoneInput(
{ variant, size, className, defaultCountry = "US", value, onChangeText, ...props },
ref
) {
const [country, setCountry] = useState(countries.find((c) => c.code === defaultCountry) ?? countries[0]);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState("");
const dark = useColorScheme() === "dark";
const colors = useThemeColors();
const caret = colors.foreground;
// Internal state is the fallback when no `value` prop is wired (uncontrolled usage)
const fullPhone = value ?? internal;
// Strip the dial code prefix to get just the number for display
const rawNumber = fullPhone.startsWith(country.dial) ? fullPhone.slice(country.dial.length) : fullPhone.replace(/^\+\d+/, "");
const handleChange = useCallback(
(text: string) => {
const digits = text.replace(/\D/g, "");
const full = `${country.dial}${digits}`;
setInternal(full);
onChangeText?.(full);
},
[country, onChangeText]
);
return (
<View className={cn(phoneVariants({ variant, size }), className)}>
<Pressable
onPress={() => setOpen(true)}
accessible={true}
accessibilityRole="button"
accessibilityLabel={`Country: ${country.name}`}
className="flex-row items-center me-2 pe-2 border-e border-border min-h-8"
>
<Text className="text-foreground text-base">{country.dial}</Text>
<ChevronDown size={14} color={colors.mutedForeground} />
</Pressable>
<TextInput
ref={ref}
className="flex-1 text-foreground p-0 text-base"
placeholderTextColor={colors.mutedForeground}
keyboardAppearance={dark ? "dark" : "light"}
selectionColor={caret}
cursorColor={caret}
keyboardType="phone-pad"
value={rawNumber}
onChangeText={handleChange}
placeholder="Phone number"
{...props}
/>
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
<Pressable className="flex-1 bg-black/50 justify-end" onPress={() => setOpen(false)}>
<View className="bg-card rounded-t-2xl max-h-80 pb-8">
<View className="items-center py-3">
<View className="w-10 h-1 rounded-full bg-muted" />
</View>
<ScrollView>
{countries.map((c) => (
<Pressable
key={c.code}
className={cn("flex-row items-center px-5 py-3", c.code === country.code && "bg-accent")}
onPress={() => { setCountry(c); setOpen(false); }}
accessibilityRole="button"
>
<Text className="text-foreground flex-1">{c.name}</Text>
<Text className="text-muted-foreground">{c.dial}</Text>
</Pressable>
))}
</ScrollView>
</View>
</Pressable>
</Modal>
</View>
);
});

