Phone input
A phone field in two parts: the country with its dialing code, and the national number formatted as it is typed. The value is one E.164 string, ready to store.
import { Field, Label } from '@/components/fieldset'
import { PhoneInput } from '@/components/phone-input'
import { useState } from 'react'
export default function Example() {
let [phone, setPhone] = useState('')
return (
<Field>
<Label>Phone</Label>
<PhoneInput value={phone} onChange={setPhone} name="phone" defaultCountry="SN" />
</Field>
)
}<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import PhoneInput from '@/components/phone-input.vue'
import { ref } from 'vue'
const phone = ref('')
</script>
<template>
<Field>
<Label>Phone</Label>
<PhoneInput v-model="phone" name="phone" default-country="SN" />
</Field>
</template>Dependencies
Parsing and formatting come from libphonenumber-js, the flags from country-flag-icons, the same two packages as the Country select:
npm install libphonenumber-js country-flag-iconspnpm add libphonenumber-js country-flag-iconsyarn add libphonenumber-js country-flag-iconsbun add libphonenumber-js country-flag-iconsnpm install libphonenumber-js country-flag-iconspnpm add libphonenumber-js country-flag-iconsyarn add libphonenumber-js country-flag-iconsbun add libphonenumber-js country-flag-iconsInstallation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import { AsYouType, getExampleNumber, parsePhoneNumberFromString } from 'libphonenumber-js/min'
import examples from 'libphonenumber-js/mobile/examples'
import type React from 'react'
import { useState } from 'react'
import { countries, findCountryByCode, type Country } from './countries'
import { CountryFlag } from './country-flag'
import { CountryList } from './country-select'
const wrapper = {
layout: 'relative block w-full',
background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
focus: [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
],
disabled: 'opacity-50 before:shadow-none before:bg-zinc-950/5',
}
export function PhoneInput({
value,
onChange,
defaultCountry = 'US',
placeholder,
disabled = false,
invalid = false,
name,
className,
autoComplete = 'tel-national',
...props
}: {
value: string
onChange: (value: string) => void
defaultCountry?: string
placeholder?: string
disabled?: boolean
invalid?: boolean
name?: string
} & Omit<React.ComponentPropsWithoutRef<'input'>, 'value' | 'onChange' | 'type' | 'children'>) {
let [selectedCountry, setSelectedCountry] = useState<Country | null>(null)
let [typed, setTyped] = useState<{ text: string; value: string } | null>(null)
let parsed = value ? parsePhoneNumberFromString(value) : undefined
let country: Country =
(parsed?.country && findCountryByCode(parsed.country)) ??
selectedCountry ??
findCountryByCode(defaultCountry) ??
countries[0]
let nationalNumber =
typed && typed.value === value
? typed.text
: !value
? ''
: parsed
? parsed.formatNational()
: value.startsWith(country.dialCode)
? value.slice(country.dialCode.length).trim()
: value
let example = placeholder ?? getExampleNumber(country.code, examples)?.formatNational() ?? ''
function selectCountry(next: Country) {
setSelectedCountry(next)
setTyped(null)
let digits = nationalNumber.replace(/\D/gu, '')
if (digits.length === 0) {
onChange('')
return
}
let formatted = `${next.dialCode}${digits}`
onChange(parsePhoneNumberFromString(formatted, next.code)?.number ?? formatted)
}
function onNumberInput(event: React.ChangeEvent<HTMLInputElement>) {
let raw = event.target.value
if (raw.trim().length === 0) {
setTyped(null)
onChange('')
return
}
let formatter = new AsYouType(country.code)
let text = formatter.input(raw)
let digits = raw.replace(/\D/gu, '')
let next = formatter.getNumber()?.number ?? (digits.length === 0 ? '' : `${country.dialCode}${digits}`)
setTyped({ text, value: next })
onChange(next)
}
return (
<span
data-slot="control"
className={clsx(
className,
wrapper.layout,
wrapper.background,
wrapper.focus,
disabled && wrapper.disabled
)}
>
{name && <input type="hidden" name={name} value={value} />}
<span
className={clsx(
'relative flex w-full items-center rounded-lg border bg-transparent dark:bg-white/5',
invalid
? 'border-red-500 hover:border-red-500 dark:border-red-600 dark:hover:border-red-600'
: 'border-zinc-950/10 hover:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20'
)}
>
<Popover>
{({ open, close }) => (
<>
<PopoverButton
aria-label="Country code"
disabled={disabled}
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-lg py-[calc(--spacing(2.5)-1px)] pr-2 pl-[calc(--spacing(3.5)-1px)] focus:outline-hidden disabled:cursor-not-allowed sm:py-[calc(--spacing(1.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]"
>
<CountryFlag code={country.code} />
<span className="text-base/6 text-zinc-950 tabular-nums sm:text-sm/6 dark:text-white">
{country.dialCode}
</span>
<ChevronDownIcon
className={clsx(
'size-4 shrink-0 text-zinc-500 transition-transform dark:text-zinc-400',
open && 'rotate-180'
)}
/>
</PopoverButton>
<PopoverPanel
anchor={{ to: 'bottom start', gap: 6, padding: 8 }}
className="z-50 w-[min(20rem,92vw)] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
>
<CountryList
selected={country.code}
dialCodes
placeholder="Country or dial code"
onSelect={(next) => {
selectCountry(next)
close()
}}
/>
</PopoverPanel>
</>
)}
</Popover>
<Headless.Input
{...props}
type="tel"
autoComplete={autoComplete}
value={nationalNumber}
placeholder={example}
disabled={disabled}
invalid={invalid}
onChange={onNumberInput}
className="w-full min-w-0 appearance-none rounded-r-lg bg-transparent py-[calc(--spacing(2.5)-1px)] pr-[calc(--spacing(3.5)-1px)] pl-2 text-base/6 text-zinc-950 placeholder:text-zinc-500 focus:outline-hidden sm:py-[calc(--spacing(1.5)-1px)] sm:pr-[calc(--spacing(3)-1px)] sm:text-sm/6 dark:text-white"
/>
</span>
</span>
)
}Also copy Country select.
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/16/solid'
import { AsYouType, getExampleNumber, parsePhoneNumberFromString } from 'libphonenumber-js/min'
import examples from 'libphonenumber-js/mobile/examples'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { computed, ref, useAttrs } from 'vue'
import { countries, findCountryByCode, type Country } from './countries'
import CountryFlag from './country-flag.vue'
import CountryList from './country-list.vue'
import { useDisabled, useFieldControl } from './fields'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
defaultCountry?: string
placeholder?: string
disabled?: boolean
invalid?: boolean
name?: string
}>(),
{ defaultCountry: 'US', placeholder: undefined, disabled: false, invalid: false, name: undefined }
)
const model = defineModel<string>({ default: '' })
const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)
const isDisabled = useDisabled(() => props.disabled)
const wrapper = {
layout: 'relative block w-full',
background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
focus: [
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
],
disabled: 'opacity-50 before:shadow-none before:bg-zinc-950/5',
}
const open = ref(false)
const selectedCountry = ref<Country | null>(null)
const typed = ref<{ text: string; value: string } | null>(null)
const parsed = computed(() => (model.value ? parsePhoneNumberFromString(model.value) : undefined))
const country = computed<Country>(
() =>
(parsed.value?.country && findCountryByCode(parsed.value.country)) ||
selectedCountry.value ||
findCountryByCode(props.defaultCountry) ||
countries[0]!
)
const inputAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
const nationalNumber = computed(() => {
if (typed.value && typed.value.value === model.value) return typed.value.text
if (!model.value) return ''
if (parsed.value) return parsed.value.formatNational()
if (model.value.startsWith(country.value.dialCode)) return model.value.slice(country.value.dialCode.length).trim()
return model.value
})
const example = computed(
() => props.placeholder ?? getExampleNumber(country.value.code, examples)?.formatNational() ?? ''
)
function selectCountry(next: Country) {
selectedCountry.value = next
typed.value = null
const digits = nationalNumber.value.replace(/\D/gu, '')
if (digits.length === 0) {
model.value = ''
} else {
const formatted = `${next.dialCode}${digits}`
model.value = parsePhoneNumberFromString(formatted, next.code)?.number ?? formatted
}
open.value = false
}
function onNumberInput(event: Event) {
const target = event.target as HTMLInputElement
const raw = target.value
if (raw.trim().length === 0) {
typed.value = null
model.value = ''
target.value = ''
return
}
const formatter = new AsYouType(country.value.code)
const text = formatter.input(raw)
const digits = raw.replace(/\D/gu, '')
const next = formatter.getNumber()?.number ?? (digits.length === 0 ? '' : `${country.value.dialCode}${digits}`)
typed.value = { text, value: next }
model.value = next
target.value = nationalNumber.value
}
</script>
<template>
<span
data-slot="control"
:class="[
attrs.class,
wrapper.layout,
wrapper.background,
wrapper.focus,
isDisabled && wrapper.disabled,
]"
>
<input v-if="name" type="hidden" :name="name" :value="model" />
<span
class="relative flex w-full items-center rounded-lg border bg-transparent dark:bg-white/5"
:class="
invalid
? 'border-red-500 hover:border-red-500 dark:border-red-600 dark:hover:border-red-600'
: 'border-zinc-950/10 hover:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20'
"
>
<PopoverRoot v-model:open="open">
<PopoverTrigger
type="button"
aria-label="Country code"
:disabled="isDisabled"
class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-lg py-[calc(--spacing(2.5)-1px)] pr-2 pl-[calc(--spacing(3.5)-1px)] focus:outline-hidden disabled:cursor-not-allowed sm:py-[calc(--spacing(1.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]"
>
<CountryFlag :code="country.code" />
<span class="text-base/6 text-zinc-950 tabular-nums sm:text-sm/6 dark:text-white">{{ country.dialCode }}</span>
<ChevronDownIcon
class="size-4 shrink-0 text-zinc-500 transition-transform dark:text-zinc-400"
:class="open && 'rotate-180'"
/>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
align="start"
:side-offset="6"
:collision-padding="8"
class="z-50 w-[min(20rem,92vw)] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
@open-auto-focus.prevent
>
<CountryList :selected="country.code" dial-codes placeholder="Country or dial code" @select="selectCountry" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<input
v-bind="inputAttrs"
:id="fieldControl.id.value"
type="tel"
:autocomplete="(attrs.autocomplete as string | undefined) ?? 'tel-national'"
:value="nationalNumber"
:placeholder="example"
:disabled="isDisabled"
:data-disabled="isDisabled ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:data-invalid="invalid ? '' : undefined"
:aria-describedby="fieldControl.describedBy.value"
class="w-full min-w-0 appearance-none rounded-r-lg bg-transparent py-[calc(--spacing(2.5)-1px)] pr-[calc(--spacing(3.5)-1px)] pl-2 text-base/6 text-zinc-950 placeholder:text-zinc-500 focus:outline-hidden sm:py-[calc(--spacing(1.5)-1px)] sm:pr-[calc(--spacing(3)-1px)] sm:text-sm/6 dark:text-white"
@input="onNumberInput"
/>
</span>
</span>
</template>Also copy Country select.
Component API
| Prop | Default | Description |
|---|---|---|
PhoneInputrenders a <input> element | ||
value | - | The number in E.164, `+237671234567`, or `''`. |
onChange | - | Called with the E.164 value on every keystroke, or `''` when the field empties. |
defaultCountry | 'US' | Country shown before anything is typed. |
placeholder | - | Replaces the example number of the current country. |
disabled | false | Disables both the country button and the number. |
invalid | false | Red border and `aria-invalid`. Validation is yours: the field formats, it does not judge. |
name | - | Adds a hidden input carrying the E.164 value, for a plain form post. |
autoComplete | 'tel-national' | Autofill hint of the number field. |
className | - | Classes of the wrapper. Every other prop goes to the number field. |
| Prop | Default | Description |
|---|---|---|
PhoneInputrenders a <input> element | ||
v-model | '' | The number in E.164, `+237671234567`, or `''`. |
defaultCountry | 'US' | Country shown before anything is typed. |
placeholder | - | Replaces the example number of the current country. |
disabled | false | Disables both the country button and the number. Also follows the `Field`. |
invalid | false | Red border and `aria-invalid`. Validation is yours: the field formats, it does not judge. |
name | - | Adds a hidden input carrying the E.164 value, for a plain form post. |
autocomplete | 'tel-national' | Autofill hint of the number field. |
class | - | Classes of the wrapper. Every other attribute goes to the number field. |
Examples
Basic example
Set defaultCountry to where most of your users are. The placeholder is a real mobile number of that country, and changes with it. The country button opens a list searchable by name or dialing code:
import { Field, Label } from '@/components/fieldset'
import { PhoneInput } from '@/components/phone-input'
import { useState } from 'react'
export default function Example() {
let [phone, setPhone] = useState('')
return (
<Field>
<Label>Phone</Label>
<PhoneInput value={phone} onChange={setPhone} name="phone" defaultCountry="SN" />
</Field>
)
}<script setup lang="ts">
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import PhoneInput from '@/components/phone-input.vue'
import { ref } from 'vue'
const phone = ref('')
</script>
<template>
<Field>
<Label>Phone</Label>
<PhoneInput v-model="phone" name="phone" default-country="SN" />
</Field>
</template>From a stored value
Pass an E.164 number and the field splits it: the country from the prefix, the rest formatted the national way. The country is derived from the number whenever it can be:
The country follows the number: type a French one and the flag changes.
<PhoneInput value="+237671234567" onChange={setPhone} /><PhoneInput v-model="business" />Invalid
The field never rejects input, so validate the value yourself and pass invalid. isValidPhoneNumber from the same library does the check:
This number is too short.
import { isValidPhoneNumber } from 'libphonenumber-js/min'
let invalid = phone !== '' && !isValidPhoneNumber(phone)
<Field>
<Label>Phone</Label>
<PhoneInput value={phone} onChange={setPhone} invalid={invalid} />
{invalid && <ErrorMessage>This number is too short.</ErrorMessage>}
</Field><script setup lang="ts">
import { isValidPhoneNumber } from 'libphonenumber-js/min'
const invalid = computed(() => phone.value !== '' && !isValidPhoneNumber(phone.value))
</script>
<template>
<Field>
<Label>Phone</Label>
<PhoneInput v-model="phone" :invalid="invalid" />
<ErrorMessage v-if="invalid">This number is too short.</ErrorMessage>
</Field>
</template>