# Phone Input

An international phone input with country select and formatting.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/phone-input.json
```

[Registry JSON](https://mwui.vercel.app/r/phone-input.json)

## Preview

```tsx
import * as React from "react";

import { PhoneInput } from "@/components/ui/phone-input";

export function Preview() {
  const [phone, setPhone] = React.useState("");

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <PhoneInput value={phone} onValueChange={setPhone} defaultCountry="PT" />

      <p className="font-mono text-xs text-muted-foreground">{phone || '""'}</p>

      <p className="text-xs text-muted-foreground">
        Try pasting <span className="font-mono">+44 7700 900123</span> — the country follows the
        number.
      </p>
    </div>
  );
}
```


## Source

### ui/phone-input.tsx

```tsx
"use client";

import { Combobox } from "@base-ui/react/combobox";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import * as React from "react";

import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/utils";

type PhoneCountry = {
  code: string;
  name: string;
  dial: string;
  groups?: number[];
};

const DEFAULT_COUNTRIES: PhoneCountry[] = [
  { code: "US", name: "United States", dial: "1", groups: [3, 3, 4] },
  { code: "CA", name: "Canada", dial: "1", groups: [3, 3, 4] },
  { code: "GB", name: "United Kingdom", dial: "44", groups: [4, 6] },
  { code: "IE", name: "Ireland", dial: "353", groups: [2, 3, 4] },
  { code: "PT", name: "Portugal", dial: "351", groups: [3, 3, 3] },
  { code: "ES", name: "Spain", dial: "34", groups: [3, 3, 3] },
  { code: "FR", name: "France", dial: "33", groups: [1, 2, 2, 2, 2] },
  { code: "DE", name: "Germany", dial: "49", groups: [4, 7] },
  { code: "IT", name: "Italy", dial: "39", groups: [3, 3, 4] },
  { code: "NL", name: "Netherlands", dial: "31", groups: [2, 3, 4] },
  { code: "BE", name: "Belgium", dial: "32", groups: [3, 2, 2, 2] },
  { code: "LU", name: "Luxembourg", dial: "352", groups: [3, 3, 3] },
  { code: "CH", name: "Switzerland", dial: "41", groups: [2, 3, 2, 2] },
  { code: "AT", name: "Austria", dial: "43", groups: [3, 3, 4] },
  { code: "DK", name: "Denmark", dial: "45", groups: [2, 2, 2, 2] },
  { code: "SE", name: "Sweden", dial: "46", groups: [2, 3, 2, 2] },
  { code: "NO", name: "Norway", dial: "47", groups: [3, 2, 3] },
  { code: "FI", name: "Finland", dial: "358", groups: [2, 3, 3] },
  { code: "IS", name: "Iceland", dial: "354", groups: [3, 4] },
  { code: "PL", name: "Poland", dial: "48", groups: [3, 3, 3] },
  { code: "CZ", name: "Czechia", dial: "420", groups: [3, 3, 3] },
  { code: "SK", name: "Slovakia", dial: "421", groups: [3, 3, 3] },
  { code: "HU", name: "Hungary", dial: "36", groups: [2, 3, 4] },
  { code: "RO", name: "Romania", dial: "40", groups: [3, 3, 3] },
  { code: "BG", name: "Bulgaria", dial: "359", groups: [3, 3, 3] },
  { code: "GR", name: "Greece", dial: "30", groups: [3, 3, 4] },
  { code: "HR", name: "Croatia", dial: "385", groups: [2, 3, 4] },
  { code: "SI", name: "Slovenia", dial: "386", groups: [2, 3, 3] },
  { code: "RS", name: "Serbia", dial: "381", groups: [2, 3, 4] },
  { code: "UA", name: "Ukraine", dial: "380", groups: [2, 3, 4] },
  { code: "TR", name: "Türkiye", dial: "90", groups: [3, 3, 4] },
  { code: "RU", name: "Russia", dial: "7", groups: [3, 3, 2, 2] },
  { code: "EE", name: "Estonia", dial: "372", groups: [4, 4] },
  { code: "LV", name: "Latvia", dial: "371", groups: [4, 4] },
  { code: "LT", name: "Lithuania", dial: "370", groups: [3, 5] },
  { code: "BR", name: "Brazil", dial: "55", groups: [2, 5, 4] },
  { code: "MX", name: "Mexico", dial: "52", groups: [2, 4, 4] },
  { code: "AR", name: "Argentina", dial: "54", groups: [2, 4, 4] },
  { code: "CL", name: "Chile", dial: "56", groups: [1, 4, 4] },
  { code: "CO", name: "Colombia", dial: "57", groups: [3, 3, 4] },
  { code: "PE", name: "Peru", dial: "51", groups: [3, 3, 3] },
  { code: "AU", name: "Australia", dial: "61", groups: [3, 3, 3] },
  { code: "NZ", name: "New Zealand", dial: "64", groups: [2, 3, 4] },
  { code: "JP", name: "Japan", dial: "81", groups: [2, 4, 4] },
  { code: "KR", name: "South Korea", dial: "82", groups: [2, 4, 4] },
  { code: "CN", name: "China", dial: "86", groups: [3, 4, 4] },
  { code: "HK", name: "Hong Kong", dial: "852", groups: [4, 4] },
  { code: "SG", name: "Singapore", dial: "65", groups: [4, 4] },
  { code: "IN", name: "India", dial: "91", groups: [5, 5] },
  { code: "ID", name: "Indonesia", dial: "62", groups: [3, 4, 4] },
  { code: "MY", name: "Malaysia", dial: "60", groups: [2, 4, 4] },
  { code: "TH", name: "Thailand", dial: "66", groups: [2, 3, 4] },
  { code: "PH", name: "Philippines", dial: "63", groups: [3, 3, 4] },
  { code: "VN", name: "Vietnam", dial: "84", groups: [3, 4, 3] },
  { code: "AE", name: "United Arab Emirates", dial: "971", groups: [2, 3, 4] },
  { code: "SA", name: "Saudi Arabia", dial: "966", groups: [2, 3, 4] },
  { code: "IL", name: "Israel", dial: "972", groups: [2, 3, 4] },
  { code: "EG", name: "Egypt", dial: "20", groups: [3, 3, 4] },
  { code: "MA", name: "Morocco", dial: "212", groups: [3, 3, 3] },
  { code: "NG", name: "Nigeria", dial: "234", groups: [3, 3, 4] },
  { code: "KE", name: "Kenya", dial: "254", groups: [3, 3, 3] },
  { code: "ZA", name: "South Africa", dial: "27", groups: [2, 3, 4] },
];

type PhoneInputProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "value"> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  defaultCountry?: string;
  onCountryChange?: (country: PhoneCountry) => void;
  countries?: PhoneCountry[];
  placeholder?: string;
  disabled?: boolean;
  name?: string;
  "aria-label"?: string;
};

function PhoneInput({
  value,
  defaultValue = "",
  onValueChange,
  defaultCountry = "US",
  onCountryChange,
  countries = DEFAULT_COUNTRIES,
  placeholder = "Phone number",
  disabled = false,
  name,
  className,
  "aria-label": ariaLabel = "Phone number",
  ...props
}: PhoneInputProps) {
  const [phone, setPhone] = useControllableState<string>({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  const fallback = countries.find((country) => country.code === defaultCountry) ?? countries[0];

  const [selected, setSelected] = React.useState<PhoneCountry>(
    () => matchCountry(phone, countries) ?? fallback,
  );

  const matched = matchCountry(phone, countries);
  const country = matched && !phone.startsWith(`+${selected.dial}`) ? matched : selected;
  const national = phone.startsWith(`+${country.dial}`) ? phone.slice(country.dial.length + 1) : "";

  const fieldRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const emit = (nextCountry: PhoneCountry, digits: string) => {
    setPhone(digits ? `+${nextCountry.dial}${digits}` : "");
  };

  const handleCountryChange = (nextCountry: PhoneCountry | null) => {
    if (!nextCountry) return;

    setSelected(nextCountry);
    onCountryChange?.(nextCountry);
    emit(nextCountry, national);
    inputRef.current?.focus();
  };

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const raw = event.target.value;

    // Typing or pasting a full international number switches country.
    if (raw.trimStart().startsWith("+")) {
      const digits = onlyDigits(raw);
      const parsed = matchDialCode(digits, countries);

      if (parsed) {
        setSelected(parsed);
        onCountryChange?.(parsed);
        setPhone(`+${digits}`);
        return;
      }
    }

    emit(country, onlyDigits(raw));
  };

  return (
    <div
      ref={fieldRef}
      data-slot="phone-input"
      data-disabled={disabled || undefined}
      className={cn(
        "flex h-8 w-full items-center rounded-lg border border-input bg-transparent text-sm transition-colors",
        "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        className,
      )}
      {...props}
    >
      <Combobox.Root
        items={countries}
        value={country}
        onValueChange={handleCountryChange}
        isItemEqualToValue={(a: PhoneCountry, b: PhoneCountry) => a.code === b.code}
        itemToStringLabel={(item: PhoneCountry) => `${item.name} +${item.dial}`}
        itemToStringValue={(item: PhoneCountry) => item.code}
        disabled={disabled}
      >
        <Combobox.Trigger
          aria-label={`Country: ${country.name}`}
          className="flex h-full shrink-0 items-center gap-1 rounded-l-lg border-r border-input px-2 outline-none hover:bg-muted focus-visible:bg-muted"
        >
          <span aria-hidden="true">{toFlag(country.code)}</span>
          <span className="text-muted-foreground tabular-nums">+{country.dial}</span>
          <Combobox.Icon>
            <IconChevronDown className="size-3 text-muted-foreground" />
          </Combobox.Icon>
        </Combobox.Trigger>

        <Combobox.Portal>
          <Combobox.Positioner anchor={fieldRef} sideOffset={4} align="start" className="z-50">
            <Combobox.Popup className="flex max-h-72 w-72 origin-(--transform-origin) flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md transition-[transform,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0">
              <div className="border-b border-border p-1">
                <Combobox.Input
                  placeholder="Search country…"
                  className="h-7 w-full rounded-md bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
                />
              </div>

              <Combobox.Empty className="px-3 py-4 text-sm text-muted-foreground empty:p-0">
                No country found.
              </Combobox.Empty>

              <Combobox.List className="overflow-y-auto overscroll-contain p-1">
                {(item: PhoneCountry) => (
                  <Combobox.Item
                    key={item.code}
                    value={item}
                    className="flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none data-highlighted:bg-muted"
                  >
                    <span className="flex size-4 shrink-0 items-center justify-center">
                      <Combobox.ItemIndicator>
                        <IconCheck className="size-4" />
                      </Combobox.ItemIndicator>
                    </span>
                    <span aria-hidden="true">{toFlag(item.code)}</span>
                    <span className="flex-1 truncate">{item.name}</span>
                    <span className="text-xs text-muted-foreground tabular-nums">+{item.dial}</span>
                  </Combobox.Item>
                )}
              </Combobox.List>
            </Combobox.Popup>
          </Combobox.Positioner>
        </Combobox.Portal>
      </Combobox.Root>

      <input
        ref={inputRef}
        type="tel"
        inputMode="tel"
        autoComplete="tel"
        aria-label={ariaLabel}
        placeholder={placeholder}
        disabled={disabled}
        value={formatNational(national, country.groups)}
        onChange={handleInputChange}
        className="h-full min-w-0 flex-1 bg-transparent px-2 outline-none placeholder:text-muted-foreground"
      />

      {name ? <input type="hidden" name={name} value={phone} readOnly /> : null}
    </div>
  );
}

function matchCountry(phone: string, countries: PhoneCountry[]): PhoneCountry | null {
  if (!phone.startsWith("+")) return null;

  return matchDialCode(phone.slice(1), countries);
}

function matchDialCode(digits: string, countries: PhoneCountry[]): PhoneCountry | null {
  let best: PhoneCountry | null = null;

  for (const country of countries) {
    if (!digits.startsWith(country.dial)) continue;
    if (!best || country.dial.length > best.dial.length) best = country;
  }

  return best;
}

function formatNational(digits: string, groups?: number[]): string {
  if (!digits) return "";

  const sizes = groups ?? [3, 3, 3, 3];
  const parts: string[] = [];

  let cursor = 0;

  for (const size of sizes) {
    if (cursor >= digits.length) break;

    parts.push(digits.slice(cursor, cursor + size));
    cursor += size;
  }

  if (cursor < digits.length) parts.push(digits.slice(cursor));

  return parts.join(" ");
}

function onlyDigits(value: string): string {
  return value.replace(/\D/gu, "");
}

function toFlag(code: string): string {
  return String.fromCodePoint(
    ...Array.from(code.toUpperCase(), (character) => 0x1_f1_a5 + character.charCodeAt(0)),
  );
}

export { PhoneInput, DEFAULT_COUNTRIES, type PhoneCountry, type PhoneInputProps };
```



## Usage

A searchable country picker joined to a number field, storing one E.164 string. Country lookup and
digit grouping ship in the component, so nothing here pulls in a metadata library.

```tsx
import { PhoneInput } from "@/components/ui/phone-input";

export function ContactField() {
  const [phone, setPhone] = React.useState("");

  return <PhoneInput value={phone} onValueChange={setPhone} defaultCountry="PT" />;
}
```

The value is `"+351912345678"` — country code included, no spaces, no punctuation. That is what
`tel:` links, SMS providers, and every phone column want, and it is the one format that survives a
user changing country later.

## Pasting a full number

Paste `+44 7700 900123` into the field and the country switches to the United Kingdom on its own,
because the dial code is in the string. Longest-prefix matching means `+1` picks the US while `+351`
picks Portugal rather than a shorter partial match.

Setting `value` from outside behaves the same way, so a number loaded from your API renders with the
right flag without you resolving it first.

## Countries

`countries` replaces the built-in list — restrict it to where you actually operate, reorder it to
put your main market first, or extend it:

```tsx
import { PhoneInput, DEFAULT_COUNTRIES } from "@/components/ui/phone-input";

<PhoneInput countries={DEFAULT_COUNTRIES.filter((country) => EU.includes(country.code))} />;
```

Each entry is `{ code, name, dial, groups? }`. `groups` is the digit grouping used for display —
`[3, 3, 3]` renders `912 345 678`. It only affects presentation; the stored value never contains
spaces.

Flags are emoji derived from the ISO code, so there are no image assets to host and nothing to
localize.

## Scope

Formatting here is grouping, not validation. Real phone-number rules — valid prefixes per carrier,
national trunk digits, variable lengths — need a metadata library such as `libphonenumber-js`, which
is roughly 145 kB. If you need to *verify* a number, validate on the server; if you need it to look
right as it is typed, this is enough.

## Props

| Prop              | Description                                                        |
| ----------------- | ------------------------------------------------------------------ |
| `value` / `defaultValue` / `onValueChange` | Controlled or uncontrolled E.164 string. |
| `defaultCountry`  | ISO code selected before the user picks one. Defaults to `"US"`.    |
| `onCountryChange` | Called with the country whenever it changes.                        |
| `countries`       | Replace the built-in list.                                          |
| `name`            | Emits a hidden input with the E.164 value for plain HTML forms.     |
| `aria-label`      | Labels the number field. Defaults to `"Phone number"`.              |

## Accessibility

The country trigger announces the current country rather than only showing a flag, since flag emoji
read as the country name in some screen readers and as nothing in others. The number field is a
real `type="tel"` input with `autocomplete="tel"`, so browser autofill works.

