AniUI

Phone Input

Phone number input with country code selector.

Installation#

npx @aniui/cli add phone-input
Web 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"
defaultCountry
string
"US"
onChangeText
(phone: string, dial: string, raw: string) => void
-
className
string
-

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>
  );
});