Skip to content
  1. Inputs
  2. Phone input

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.

Value: (empty)

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-icons
pnpm add libphonenumber-js country-flag-icons
yarn add libphonenumber-js country-flag-icons
bun add libphonenumber-js country-flag-icons
npm install libphonenumber-js country-flag-icons
pnpm add libphonenumber-js country-flag-icons
yarn add libphonenumber-js country-flag-icons
bun add libphonenumber-js country-flag-icons

Installation

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

PropDefaultDescription
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.
disabledfalseDisables both the country button and the number.
invalidfalseRed 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.
PropDefaultDescription
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.
disabledfalseDisables both the country button and the number. Also follows the `Field`.
invalidfalseRed 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:

Value: (empty)

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>