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-inputNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { PhoneInput, type PhoneValue } from "@/components/ui/phone-input"
export default function PhoneInputDemo() {
const id = React.useId()
const [phone, setPhone] = React.useState<PhoneValue | undefined>("+442079460123")
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor={`${id}-phone`}>Phone number</Label>
<PhoneInput id={`${id}-phone`} aria-describedby={`${id}-hint`} value={phone} onValueChange={setPhone} />
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
Pick a country, or paste a number with its + and the country follows.
</p>
<p role="status" className="text-sm text-muted-foreground">
{phone ? (
<>
Stored as <span className="font-mono text-foreground">{phone}</span>
</>
) : (
"Not a whole number yet"
)}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | — | The number in E.164. Passing the key at all — even as undefined — makes the field controlled. |
| defaultValue | string | — | 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. |
| country | CountryCode | — | The country, when you hold it ("GB"); controlled when given. |
| defaultCountry | CountryCode | the 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. |
| locale | string | "en-US" | The language the countries are named and sorted in. |
| name | string | — | Posts the number with its form, as E.164 in a hidden input of this name (empty while it is not whole). |
| className | string | — | Merged onto the root, which holds the country and the number. |
| ...props | React.ComponentProps<"input"> | — | Everything else — id, placeholder, required, form, aria-*, onBlur — lands on the field you type in; form reaches the hidden input too. |
Dependencies
Registry
Source
"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 }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()
}