Skip to contentVibraUI
Inputs & filters

Phone input

A searchable country with its calling code beside a number formatted as it is typed; its value is E.164.

The value is E.164 ("+442079460123"), reported only once the number is whole and valid and as undefined once it is not; what the field shows is the number the way its country writes it ("020 7946 0123"). The country is the kit's Combobox as a button showing "GB +44" with the search inside — by name with or without its accents, by code or by calling code — and no flags; a calling code is isolated left to right, so a right-to-left page still reads "+44". A number pasted or filled in from abroad, with its + or a 00 (even where the country dials abroad another way), moves the country to it and is then written at home; with country held by you, onCountryChange asks you to move it and the + stays. A composite input: every prop but className and name reaches the field you type in — id, placeholder, required, aria-* — so a Label's htmlFor finds it. name posts the E.164 with the form from a hidden input, empty while the number is not whole, never the text on screen, which has no country; check it on the server with isValidPhone from phone-input/phone, which has no client boundary and reads the same metadata the field typed with (libphonenumber-js's min set): a string, in E.164, valid — anything else is refused. That metadata is the heaviest part of the item, so in a page load the field with a dynamic import when the reader first opens the form, and its first load stays light. formatPhone writes a stored number for anyone, internationally; fromE164 hands back its country and the national form; countryOptions lists the countries named in a locale and sorted by that name. Controlled by the presence of the value key: value={undefined} is no number, so a caller clearing it clears the field.

Install

npx shadcn@latest add @vibra/phone-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring | undefined—The number in E.164. Passing the key at all — even as undefined — makes the field controlled.
defaultValuestring—Where a field that holds its own number starts; its country comes with it.
onValueChange(value: string | undefined) => void—Called with the E.164 once the number is whole and valid, and with undefined when it stops being so.
countryCountryCode—The country, when you hold it ("GB"); controlled when given.
defaultCountryCountryCodethe number's own, else "US"Where a field that holds its own country starts.
onCountryChange(country: CountryCode) => void—Called when the reader picks a country, or a number from abroad asks for one.
localestring"en-US"The language the countries are named and sorted in.
namestring—Posts the number with its form, as E.164 in a hidden input of this name (empty while it is not whole).
classNamestring—Merged onto the root, which holds the country and the number.
...propsReact.ComponentProps<"input">—Everything else — id, placeholder, required, form, aria-*, onBlur — lands on the field you type in; form reaches the hidden input too.

Dependencies

Source

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

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
  ComboboxValue,
} from "@/components/ui/combobox"
import { Input } from "@/components/ui/input"
import {
  countryOptions,
  formatAsYouType,
  formatPhone,
  fromE164,
  isValidPhone,
  toE164,
  type CountryCode,
  type CountryOption,
  type PhoneValue,
} from "@/components/ui/phone-input/phone"

// One import builds the field and checks what it holds. A server action
// checks a posted number from phone-input/phone, which has no client boundary.
export { countryOptions, formatAsYouType, formatPhone, fromE164, isValidPhone, toE164 }
export type { CountryCode, CountryOption, PhoneValue }

export type PhoneInputProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type"> & {
  value?: PhoneValue | undefined
  defaultValue?: PhoneValue
  onValueChange?: (value: PhoneValue | undefined) => void
  country?: CountryCode
  defaultCountry?: CountryCode
  onCountryChange?: (country: CountryCode) => void
  locale?: string
}

/** A stored number as the field writes it: at home when it is the field's country, with its + when it is not. */
function textFor(value: PhoneValue | undefined, country: CountryCode): string {
  if (!value) return ""
  const own = fromE164(value)
  return own.country === country ? own.national : formatPhone(value)
}

/** Lower case without accents, so "reunion" finds Réunion. */
const fold = (text: string) => text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()

/** A country matches its name, its two-letter code, or the start of its calling code ("+44", "44"). */
function matches(option: CountryOption, query: string): boolean {
  const wanted = fold(query.trim())
  if (!wanted) return true
  const digits = wanted.replace(/^\+/, "")
  if (/^\d+$/.test(digits)) return option.callingCode.startsWith(digits)
  return fold(option.name).includes(wanted) || option.code.toLowerCase() === wanted
}

/**
 * A phone number field: a searchable country with its calling code, the
 * number written the way that country writes it as it is typed, and an E.164
 * value. A composite input — every prop but className and name reaches the
 * field you type in; name posts the E.164 from a hidden input, never the text
 * on screen. Controlled by the presence of the `value` key.
 */
function PhoneInput(props: PhoneInputProps) {
  const {
    value,
    defaultValue,
    onValueChange,
    country: countryProp,
    defaultCountry,
    onCountryChange,
    locale = "en-US",
    className,
    disabled,
    onBlur,
    name,
    ...inputProps
  } = props

  const controlled = "value" in props
  const [internal, setInternal] = React.useState<PhoneValue | undefined>(defaultValue)
  const current = controlled ? value : internal

  // A number's own country first, then the one asked for: the field opens on
  // the country its number belongs to.
  const [internalCountry, setInternalCountry] = React.useState<CountryCode>(
    () => (current ? fromE164(current).country : undefined) ?? defaultCountry ?? "US"
  )
  const countryControlled = countryProp !== undefined
  const country = countryProp ?? internalCountry

  // The text is the field's own while it is typed; it is rewritten only when
  // a value arrives that it does not already spell — a caller's change,
  // clearing included.
  const [text, setText] = React.useState(() => textFor(current, country))
  const [seen, setSeen] = React.useState(current)
  if (current !== seen) {
    setSeen(current)
    if (toE164(text, country) !== (current ?? null)) {
      const own = current ? fromE164(current).country : undefined
      const next = own && !countryControlled ? own : country
      if (next !== internalCountry && !countryControlled) setInternalCountry(next)
      setText(textFor(current, next))
    }
  }

  const options = React.useMemo(() => countryOptions(locale), [locale])
  const selected = options.find((option) => option.code === country) ?? null

  function report(next: PhoneValue | undefined) {
    if (next === current) return
    if (!controlled) setInternal(next)
    onValueChange?.(next)
  }

  function moveCountry(next: CountryCode) {
    if (next === country) return
    if (!countryControlled) setInternalCountry(next)
    onCountryChange?.(next)
  }

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const input = event.currentTarget
    const raw = input.value
    const typedType = (event.nativeEvent as InputEvent).inputType ?? ""
    const number = toE164(raw, country)
    const own = number ? fromE164(number).country : undefined
    // A whole number written from abroad — typed, pasted or filled in — moves
    // the country to it and is then written the way that country writes it;
    // a caller holding the country is asked to move it, and the + stays.
    if (number && own && /^\s*(\+|00)/.test(raw)) {
      moveCountry(own)
      setText(own === country || !countryControlled ? fromE164(number).national : formatAsYouType(raw, country))
    } else {
      // Spaced as it is typed at the end; an edit in the middle or a deletion
      // keeps the text as it stands, so the caret stays where it was and a
      // space can be taken out. Leaving the field tidies it.
      const atEnd = input.selectionStart === raw.length
      setText(atEnd && !typedType.startsWith("delete") ? formatAsYouType(raw, country) : raw)
    }
    report(number ?? undefined)
  }

  function chooseCountry(next: CountryCode) {
    moveCountry(next)
    report(toE164(text, next) ?? undefined)
  }

  return (
    <div
      data-slot="phone-input"
      data-country={country}
      data-disabled={disabled || undefined}
      // One field on the card plane, as Input is: the country is its first part.
      className={cn(
        "flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-card transition-colors has-[input:focus-visible]:focus-outline has-aria-invalid:border-destructive data-disabled:opacity-50",
        className
      )}
    >
      <Combobox
        items={options}
        value={selected}
        onValueChange={(option: CountryOption | null) => {
          if (option) chooseCountry(option.code)
        }}
        itemToStringLabel={(option: CountryOption) => `${option.code} +${option.callingCode}`}
        isItemEqualToValue={(a: CountryOption, b: CountryOption) => a.code === b.code}
        filter={matches}
        autoHighlight
        disabled={disabled}
      >
        <ComboboxTrigger
          aria-label="Country"
          disabled={disabled}
          className="flex h-full shrink-0 items-center gap-1 rounded-s-lg border-e border-input ps-2.5 pe-1.5 text-sm tabular-nums transition-colors focus-ring-inset hover:bg-muted disabled:pointer-events-none"
        >
          <ComboboxValue>
            {(option: CountryOption | null) =>
              option ? (
                <>
                  {/* One Latin label, kept left to right whole on a right-to-left page. */}
                  <span aria-hidden="true" dir="ltr">
                    {option.code} +{option.callingCode}
                  </span>
                  <span className="sr-only">
                    {option.name} <span dir="ltr">+{option.callingCode}</span>
                  </span>
                </>
              ) : null
            }
          </ComboboxValue>
        </ComboboxTrigger>
        <ComboboxContent className="w-72">
          <ComboboxInput showTrigger={false} placeholder="Search countries" aria-label="Search countries" />
          <ComboboxEmpty>No country by that name.</ComboboxEmpty>
          <ComboboxList>
            {(option: CountryOption) => (
              <ComboboxItem key={option.code} value={option}>
                <span className="truncate">{option.name}</span>{" "}
                {/* The outer span is the column at the row's end, in the page's
                    direction; the inner one isolates the code left to right, or
                    a right-to-left page would draw "44+". On one element the
                    dir would turn ms-auto into a left margin there. */}
                <span className="ms-auto shrink-0 text-muted-foreground tabular-nums">
                  <bdi dir="ltr">+{option.callingCode}</bdi>
                </span>
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
      <Input
        type="tel"
        inputMode="tel"
        autoComplete="tel"
        disabled={disabled}
        value={text}
        onChange={handleChange}
        onBlur={(event) => {
          const number = toE164(text, country)
          setText(number ? textFor(number, country) : formatAsYouType(text, country))
          onBlur?.(event)
        }}
        className="h-full min-w-0 flex-1 rounded-none border-0 bg-transparent px-2.5 tabular-nums shadow-none focus-visible:outline-none disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent"
        {...inputProps}
      />
      {/* What a form posts is the value, as every kit field's name does: the
          E.164, or nothing while the number is not whole. The text on screen
          has no country, and the server's isValidPhone would refuse it. */}
      {name ? <input type="hidden" name={name} value={current ?? ""} disabled={disabled} form={inputProps.form} /> : null}
    </div>
  )
}

export { PhoneInput }
components/ui/phone-input/phone.ts
import {
  AsYouType,
  getCountries,
  getCountryCallingCode,
  parsePhoneNumberFromString,
  type CountryCode,
  type PhoneNumber,
} from "libphonenumber-js/min"

export type { CountryCode }

/** A phone number as the kit stores it: E.164, "+442079460123" — a plus, the calling code, the national number, nothing else. */
export type PhoneValue = string

export type CountryOption = { code: CountryCode; name: string; callingCode: string }

const E164 = /^\+[1-9]\d{1,14}$/

/**
 * What a person typed, as a number: read against `country` when it is
 * national, on its own when it starts with a plus or the country's own
 * international prefix. A "00" that country does not dial abroad with (the
 * US dials 011) is still read as a plus, because that is what it meant.
 */
function parse(text: string, country: CountryCode): PhoneNumber | undefined {
  const parsed = parsePhoneNumberFromString(text, { defaultCountry: country })
  if (parsed?.isValid()) return parsed
  const compact = text.replace(/[\s().-]/g, "")
  if (!compact.startsWith("00")) return undefined
  const abroad = parsePhoneNumberFromString(`+${compact.slice(2)}`)
  return abroad?.isValid() ? abroad : undefined
}

/** The E.164 form of a typed number — spaces, dashes, brackets and a "00" all read — or null until it is a whole, valid number. */
export function toE164(text: string, country: CountryCode): PhoneValue | null {
  return parse(text, country)?.number ?? null
}

/** A number as it is written while it is typed: "02079460123" in Great Britain reads "020 7946 0123". */
export function formatAsYouType(text: string, country: CountryCode): string {
  return new AsYouType(country).input(text)
}

/** A stored number back as its country and the way that country writes it at home. */
export function fromE164(value: PhoneValue): { country?: CountryCode; national: string } {
  const parsed = parsePhoneNumberFromString(value)
  if (!parsed) return { national: value }
  return { country: parsed.country, national: parsed.formatNational() }
}

/** A stored number written for anyone, anywhere: "+44 20 7946 0123". */
export function formatPhone(value: PhoneValue): string {
  return parsePhoneNumberFromString(value)?.formatInternational() ?? value
}

/**
 * Whether a value is a whole, valid number in E.164 — the check a server
 * action makes on what a form sent, with the same metadata the field typed
 * against. A number with spaces, without its plus, or of any other type is not.
 */
export function isValidPhone(value: unknown): value is PhoneValue {
  if (typeof value !== "string" || !E164.test(value)) return false
  const parsed = parsePhoneNumberFromString(value)
  return parsed !== undefined && parsed.isValid() && parsed.number === value
}

const optionsByLocale = new Map<string, CountryOption[]>()

/**
 * Every country the metadata knows, named in `locale` and sorted by that name,
 * with its calling code. Built once per locale; each call hands back its own
 * copy of the list.
 */
export function countryOptions(locale = "en-US"): CountryOption[] {
  let options = optionsByLocale.get(locale)
  if (!options) {
    const names = new Intl.DisplayNames([locale], { type: "region" })
    options = getCountries()
      .map((code) => ({ code, name: names.of(code) ?? code, callingCode: getCountryCallingCode(code) }))
      .sort((a, b) => a.name.localeCompare(b.name, locale))
    optionsByLocale.set(locale, options)
  }
  return options.slice()
}