Phone Input

Phone number field with a searchable country picker, formatting as you type and E.164 output.

@drift-ui/phone-inputRegistry JSON

Saved as +14155550123

Try it: paste +44 20 7946 0958, or pick a country and keep typing.

On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

Usage

phone-input-usage.tsx
"use client"

import * as React from "react"

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

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

  return (
    <PhoneInput
      aria-label="Phone number"
      value={phone}
      onValueChange={setPhone}
      defaultCountry="US"
    />
  )
}

Examples

Default country

defaultCountry sets the starting country until the value or the person typing picks another one.

Limit the country list

countries lists only these, in this order. Numbers from anywhere else are rejected, so try pasting +49 151 23456789.

Exclude countries

excludedCountries keeps every country except the ones you name.

Custom names and flags

Pass objects to translate names or replace the emoji flag with any React node. Windows browsers show emoji flags as two letters, so this is also how to use your own images.

Validation

PhoneInput does not validate, because the value is partial while typing. Check it with isValidPhoneNumber and pass the result to aria-invalid.

Enter a complete number.

Forms

name submits the E.164 value through a hidden input, so the server never sees the formatted text.

Disabled

disabled locks the field and the country button together.

API reference

PhoneInput

A tel input with a searchable country picker. It holds an E.164 string and shows the number without its dial code, so +917424855888 reads 74248 55888 beside a +91 button. Label it with a Label and id, or with aria-label. In the picker, arrow keys move through the list, Enter selects without submitting the form, and Escape closes it and returns focus to the country button.

Prop
valuestringControlled phone number in E.164 format.
defaultValuestring= ""Initial value when uncontrolled.
onValueChange(value: string) => voidCalled on every accepted edit with the E.164 number, or "" when empty. The number is partial while typing, such as "+1415".
defaultCountryCountryCode= "US"ISO 3166-1 alpha-2 country used until the value identifies one. Falls back to the first listed country when it is not in countries.
countriesPhoneCountryOption[]= every countryThe countries to offer, in display order. Pass codes, or objects that rename a country or replace its flag.
excludedCountriesCountryCode[]Countries to remove. Applies on top of countries, and wins if a code is in both.
namestringSubmits the E.164 value through a hidden input. The visible field has no name, so forms never receive the formatted text.
disabledbooleanDisables the field and the country button.
placeholderstringText shown in the field while it is empty.
autoCompletestring= "tel"Browser autofill hint. Autofilled numbers are handled like a paste.
aria-invalidbooleanDraws the destructive border and ring around the whole field, country button included.
refReact.Ref<HTMLInputElement>Points at the tel input.
classNamestringGoes on the outer field. Inner parts carry the data-slot values phone-input-country, phone-input-field, phone-input-search and phone-input-list.

Other props go to the <input>, except type.

Country restrictions

countries and excludedCountries limit what the field accepts as well as what the picker lists. A typed or pasted number from a country outside the list is ignored, onValueChange does not fire, and a polite live region announces it. Calling codes shared by several countries are checked on the complete number, so with US only, +1 416 can be typed until the last digit, where it is blocked as Canadian. Deleting is always allowed. A disallowed value passed through props still displays, in international format.

PhoneCountryOption

type PhoneCountryOption = | CountryCode | { code: CountryCode; name?: string; flag?: React.ReactNode }

One entry in countries. Fields you leave out keep their defaults, and the dial code always comes from the code. Import the type from @/components/ui/phone-input, and CountryCode from libphonenumber-js.

Prop
codeRequiredCountryCodeISO 3166-1 alpha-2 code, such as "IN".
namestringLabel in the list and the country button's accessible name, and what search matches. Defaults to the English name from Intl.DisplayNames, so pass this to translate.
flagReact.ReactNodeReplaces the emoji flag, hidden from assistive technology. Windows browsers show emoji flags as two letters, so pass an image or SVG if that matters to you.

isValidPhoneNumber

isValidPhoneNumber(value: string): boolean

From libphonenumber-js, which this component installs. PhoneInput does not validate: value is partial while typing, so call this on submit or to set aria-invalid.

formatPhoneValue

formatPhoneValue(value: string, country: CountryCode): string

From @/lib/phone-input. Formats a saved E.164 number the way the field shows it, without the dial code, for read-only screens.

resolveCountries

resolveCountries( countries?: PhoneCountryOption[], excludedCountries?: CountryCode[] ): PhoneCountry[]

From @/lib/phone-input. Returns the list PhoneInput would show, for building your own country menu. PHONE_COUNTRIES from the same file holds every country.

filterCountries

filterCountries(countries: PhoneCountry[], query: string): PhoneCountry[]

From @/lib/phone-input. The picker's search: names ignoring case and accents, exact country codes, and dial code prefixes with or without the plus.

PhoneCountry

A resolved country, as returned by resolveCountries and PHONE_COUNTRIES.

Prop
codeRequiredCountryCodeISO 3166-1 alpha-2 code, such as "IN".
nameRequiredstringDisplay name.
dialCodeRequiredstringCalling code with its plus, such as "+91".
flagRequiredReact.ReactNodeEmoji flag, or your replacement.

Source

components/ui/phone-input.tsx
"use client"

import * as React from "react"
import type { CountryCode } from "libphonenumber-js"
import { CheckIcon, ChevronDownIcon } from "lucide-react"

import { useControllableState } from "@/hooks/use-controllable-state"
import {
  caretAfterDigits,
  changeCountry,
  countDigits,
  countryForValue,
  deleteDigitPastFormatting,
  filterCountries,
  formatInsertedPhone,
  formatPhoneInput,
  formatPhoneValue,
  isAllowedNumber,
  resolveCountries,
  type PhoneCountry,
  type PhoneCountryOption,
} from "@/lib/phone-input"
import { cn } from "@/lib/utils"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

function scrollOptionIntoList(option: HTMLElement | null) {
  const list = option?.parentElement
  if (!option || !list) return
  const bottom = option.offsetTop + option.offsetHeight
  if (option.offsetTop < list.scrollTop) {
    list.scrollTop = option.offsetTop
  } else if (bottom > list.scrollTop + list.clientHeight) {
    list.scrollTop = bottom - list.clientHeight
  }
}

function CountryPicker({
  countries,
  country,
  disabled,
  onSelect,
}: {
  countries: PhoneCountry[]
  country: CountryCode
  disabled?: boolean
  onSelect: (country: CountryCode) => void
}) {
  const listId = React.useId()
  const [isOpen, setIsOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [highlightedIndex, setHighlightedIndex] = React.useState(0)

  const current = countries.find((option) => option.code === country)
  const matches = filterCountries(countries, query)

  function handleOpenChange(nextOpen: boolean) {
    setIsOpen(nextOpen)
    if (!nextOpen) return
    setQuery("")
    setHighlightedIndex(
      Math.max(
        0,
        countries.findIndex((option) => option.code === country)
      )
    )
  }

  function select(code: CountryCode) {
    onSelect(code)
    setIsOpen(false)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    switch (event.key) {
      case "ArrowDown":
      case "ArrowUp": {
        event.preventDefault()
        if (matches.length === 0) return
        const delta = event.key === "ArrowDown" ? 1 : -1
        setHighlightedIndex(
          (highlightedIndex + delta + matches.length) % matches.length
        )
        return
      }
      case "Enter": {
        event.preventDefault()
        const match = matches[highlightedIndex]
        if (match) select(match.code)
        return
      }
    }
  }

  return (
    <Popover open={isOpen} onOpenChange={handleOpenChange}>
      <PopoverTrigger
        type="button"
        disabled={disabled}
        data-slot="phone-input-country"
        aria-label={`Country: ${current?.name}, ${current?.dialCode}`}
        className="flex shrink-0 items-center gap-1 rounded-l-md border-r border-input px-2 text-muted-foreground tabular-nums transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground disabled:cursor-not-allowed"
      >
        <span aria-hidden="true">{current?.flag}</span>
        <span>{current?.dialCode}</span>
        <ChevronDownIcon className="size-3.5 opacity-60" />
      </PopoverTrigger>
      <PopoverContent
        align="start"
        aria-label="Choose a country"
        className="flex w-72 flex-col gap-1 p-1"
      >
        <input
          type="text"
          role="combobox"
          data-slot="phone-input-search"
          aria-label="Search countries"
          aria-autocomplete="list"
          aria-expanded="true"
          aria-controls={listId}
          aria-activedescendant={
            matches.length > 0 ? `${listId}-${highlightedIndex}` : undefined
          }
          value={query}
          placeholder="Search countries"
          onChange={(event) => {
            setQuery(event.target.value)
            setHighlightedIndex(0)
          }}
          onKeyDown={handleKeyDown}
          className="h-8 w-full rounded-sm bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
        />
        <div
          id={listId}
          role="listbox"
          aria-label="Countries"
          data-slot="phone-input-list"
          className="relative max-h-64 overflow-y-auto"
        >
          {matches.length === 0 ? (
            <p className="px-2 py-1.5 text-sm text-muted-foreground">
              No results
            </p>
          ) : (
            matches.map((option, index) => (
              <div
                key={option.code}
                ref={
                  index === highlightedIndex ? scrollOptionIntoList : undefined
                }
                id={`${listId}-${index}`}
                role="option"
                aria-selected={option.code === country}
                data-highlighted={index === highlightedIndex ? "" : undefined}
                onPointerMove={() => setHighlightedIndex(index)}
                onClick={() => select(option.code)}
                className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
              >
                <span aria-hidden="true">{option.flag}</span>
                <span className="min-w-0 flex-1 truncate">{option.name}</span>
                <span className="text-muted-foreground tabular-nums">
                  {option.dialCode}
                </span>
                {option.code === country && (
                  <CheckIcon className="size-4 shrink-0" />
                )}
              </div>
            ))
          )}
        </div>
      </PopoverContent>
    </Popover>
  )
}

type PhoneInputProps = Omit<
  React.ComponentProps<"input">,
  "value" | "defaultValue" | "onChange" | "type"
> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  defaultCountry?: CountryCode
  countries?: PhoneCountryOption[]
  excludedCountries?: CountryCode[]
}

function PhoneInput({
  value,
  defaultValue = "",
  onValueChange,
  defaultCountry = "US",
  countries,
  excludedCountries,
  name,
  disabled,
  autoComplete = "tel",
  className,
  ref,
  ...props
}: PhoneInputProps) {
  const inputRef = React.useRef<HTMLInputElement>(null)
  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)

  const { value: phone, setValue: setPhone } = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  })
  const [selectedCountry, setSelectedCountry] = React.useState(defaultCountry)
  const [edit, setEdit] = React.useState({ text: "", caret: 0 })
  const [announcement, setAnnouncement] = React.useState("")

  const allowedCountries = resolveCountries(countries, excludedCountries)
  const country = countryForValue(phone, selectedCountry, allowedCountries)
  const display =
    formatPhoneInput(edit.text, country).value === phone
      ? edit.text
      : formatPhoneValue(phone, country)

  React.useLayoutEffect(() => {
    const input = inputRef.current
    if (input && document.activeElement === input) {
      input.setSelectionRange(edit.caret, edit.caret)
    }
  }, [edit])

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const { value: raw, selectionStart } = event.target
    const { inputType } = event.nativeEvent as InputEvent
    const { text, caret } = deleteDigitPastFormatting(
      display,
      raw,
      selectionStart ?? raw.length,
      inputType === "deleteContentForward"
    )
    const isWholeInsert =
      !inputType || /^insert(From|Replacement)/.test(inputType)
    const next = isWholeInsert
      ? formatInsertedPhone(text, country, allowedCountries)
      : { ...formatPhoneInput(text, country), country }
    const isDeletion = inputType?.startsWith("delete")
    if (!isDeletion && !isAllowedNumber(next.value, allowedCountries)) {
      setEdit({ text: display, caret: display.length })
      setAnnouncement("Numbers from this country are not accepted")
      return
    }
    setAnnouncement("")
    setSelectedCountry(next.country)
    setEdit({
      text: next.display,
      caret: caretAfterDigits(next.display, countDigits(text.slice(0, caret))),
    })
    setPhone(next.value)
  }

  function handleCountrySelect(next: CountryCode) {
    const { display: nextDisplay, value: nextValue } = changeCountry(
      phone,
      country,
      next
    )
    setSelectedCountry(next)
    setEdit({ text: nextDisplay, caret: nextDisplay.length })
    setPhone(nextValue)
  }

  return (
    <div
      data-slot="phone-input"
      data-disabled={disabled ? "" : undefined}
      className={cn(
        "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] md:text-sm dark:bg-input/30",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
        "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        className
      )}
    >
      <CountryPicker
        countries={allowedCountries}
        country={country}
        disabled={disabled}
        onSelect={handleCountrySelect}
      />
      <input
        {...props}
        ref={inputRef}
        type="tel"
        data-slot="phone-input-field"
        autoComplete={autoComplete}
        disabled={disabled}
        value={display}
        onChange={handleChange}
        className="min-w-0 flex-1 rounded-r-md bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
      />
      {name && (
        <input type="hidden" name={name} disabled={disabled} value={phone} />
      )}
      <span role="status" aria-live="polite" className="sr-only">
        {announcement}
      </span>
    </div>
  )
}

export {
  PhoneInput,
  type PhoneCountry,
  type PhoneCountryOption,
  type PhoneInputProps,
}