Rxova
Skip to content

Phone input — About

The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.

The single most important thing to understand about this package.

Possibility (what this does)Full validity
Question answeredIs this a length the country uses?Is this an assignable number?
+1 555 555 5555possiblenot valid
Data neededdial codes and lengths (~4 kB)carrier assignment rules
Good forstopping a typo in a formauditing a database of numbers

The API says so: the field is possible, and there is no isValid anywhere. Full validity needs the assignment rules for every carrier block in the world, and the right place to run those is the server, where the metadata costs nothing and the answer can be trusted.

import { parsePhone } from '@rxova/react-phone-input'
parsePhone('+442071234567').possible // true — 10 digits, which the UK uses
parsePhone('+4420712').possible // false — no UK number is 5 digits
parsePhone('+9912345678').possible // false — no such calling code

That last case is worth noting: an explicit + whose calling code matches nothing is never possible at any length, because the prefix tells us for certain the user meant it as international.

WhatSourceCost
Country namesIntl.DisplayNames0 — every engine ships ICU
FlagsTwo Unicode regional-indicator letters0 — six bytes of arithmetic
Calling codes, lengths, groupingThis package’s table~4 kB

On Windows a flag renders as the two letters rather than a flag. That is a legible fallback, not a broken image — and renderCountry lets you substitute anything else.

+1 covers 25 entries in the table and +7 covers two. The calling code alone genuinely cannot distinguish them, so the first table entry wins: the United States for +1, Kazakhstan for +7. That is what every phone field does. Callers who need a specific one pass country.

Calling codes are matched longest first350 (Gibraltar) and 351 (Portugal) both start with 35, so a shortest-first scan would have to guess.

Nearly everywhere, a leading 0 is a trunk prefix: dialled domestically, dropped internationally. So 020 7123 4567 in the UK is +44 20 7123 4567.

Italy is the well-known exception — the zero is part of the number, and +39 06 … is correct. Encoding that one exception is smaller and more honest than pretending the rule is universal. If another country turns out to keep its leading zero, it is one entry in a set.

Formatting inserts spaces, so the character offset the browser reports stops meaning what it meant the moment a separator appears before it. Counting digits either side of the caret is the only anchor that survives reformatting:

  1. Before the reformat, count how many digits precede the caret.
  2. After it, walk the formatted string until that many digits have been passed.

Restoration is skipped when the field is not focused, because setting a selection on an unfocused input steals focus in some engines — which would yank the page around whenever a value arrives from somewhere else.

Deleting needs its own case. The character under the caret at a group boundary is a separator this component inserted, so removing it changes no digit at all: the formatter puts it straight back, the value comes back identical and the keystroke is dead. Backspacing through 415 555 2671 cost two presses at every boundary. When a deletion leaves the digit count unchanged, the digit the user was reaching for — behind the caret for Backspace, ahead of it for Delete — is the one that goes.

Controlled country versus a typed calling code

Section titled “Controlled country versus a typed calling code”

If country is controlled as US and the user types +33 6 …, the select moves to France.

That is a deliberate reading. country controls which country national input is interpreted against; a number typed as +33 … is French by its own contents, and showing “United States” beside it would break the stronger invariant that the select and the text never disagree about what is in the field. The parent is told through onCountryChange and can reject the value.

Changing country through the select keeps the typed digits: they are the user’s input, the calling code is ours. Switching from the UK to Ireland keeps 2071234567 and re-reads it, rather than clearing a field the user did not mean to lose.

Both halves of the field are native on purpose.

  • <select>, not a custom listbox. On mobile that is the OS picker: searchable, scrollable with a thumb, already localised. No custom widget of 234 options matches it, and it gives keyboard type-ahead and form semantics for free. The cost — options cannot be richly styled — is accepted deliberately, and hideCountrySelect plus the headless hook lets you build your own.
  • <input type="tel">, not type="number". number strips leading zeros, offers a spinner nobody wants on a phone number, and refuses + entirely. inputmode="tel" gives the dial pad and autocomplete="tel" lets the browser fill it.

The E.164 value is a type="hidden" input: never focusable, never announced, because the visible controls are the accessible representation of it.

axe (WCAG 2.1 A/AA) runs over the component in the browser suite and over the whole demo page in Chromium, Firefox and WebKit.

maxLength defaults to 32 and cannot be removed, only moved.

An unbounded phone field is a denial-of-service surface. Every keystroke re-parses the contents, walks the calling-code table, re-groups the digits and re-derives where the caret should land; none of that is bounded by the number, only by the size of what was pasted. The same reasoning puts a mandatory cap on @rxova/react-password-input.

32 is derived rather than picked. E.164 caps a number at 15 digits including the calling code, so the longest text this field can ever produce is 21 characters — a +, the calling code, and the digits with their grouping separators. That figure is asserted against the country table itself in the adversarial suite, so a future entry with a longer calling code or a one-digit grouping fails the test rather than silently starting to truncate.

Half again as much room is what people’s own punctuation needs, and it is comfortably enough:

PastedCharacters
+1 (415) 555-267117
+44 (0)20 7123 456720
0044 (0)20 7123 456720

So no number written the way people write phone numbers is ever truncated, while a pasted file stops at 32 characters instead of reaching the parser.

The cap is enforced in three places, because one is not enough: maxLength lands on the <input> so the browser stops the keystroke at source; usePhoneInput truncates the incoming text, because the attribute does not apply to a programmatic value assignment and a headless renderer may not have the attribute at all; and it truncates again after formatting, because grouping inserts spaces and text already at the cap formats past it.

A maxLength below 21 cannot hold a number the component itself formats, so it is refused — the default is used and onWarn reports max-length-too-small.

onWarn receives { code, prop, received, message } whenever a prop is rejected or coerced.

CodeMeaning
unknown-countryAn ISO code not in the table, in country or countries
unknown-default-countrySame, in defaultCountry
value-not-e164A national number passed where E.164 belongs
value-country-unknownA + value whose calling code is not in the table
empty-country-listcountries was empty; the full list is used
locale-invalidIntl refused the tag; names fall back to ISO codes
max-length-too-smallmaxLength under 21 or non-finite; the default cap is used instead

With no handler these go to console.warn. The entire path is stripped from production builds — it sits behind a process.env.NODE_ENV !== 'production' branch, so there is no runtime cost and no console noise in production. The E2E suite asserts this against a real production bundle.

  • No carrier-level validity, number type, or extensions. Out of scope; see above.
  • Flags render as letters on Windows.
  • UK landline grouping is approximate. The 4-6 grouping formats a mobile (07911 123456) correctly and a London landline as 2071 234567 rather than 20 7123 4567. UK area codes are 2 to 5 digits and telling them apart needs the metadata this package deliberately does not carry.
  • US and Canada cannot be distinguished from the number alone. That needs NANP area-code data.

There is no stylesheet to import.

Set them on [data-rx-phone-root] or any ancestor:

[data-rx-phone-root] {
--rx-phone-gap: 0.375rem;
--rx-phone-select-width: 9rem;
}

These are public API, covered by semver.

AttributeOnMeaning
data-rx-phone-rootwrapperAlways present
data-countrywrapperISO code of the resolved country
data-possiblewrapperThe number is a possible length
data-invalid / data-disabledwrapperMirrors the props
data-readonlywrapperMirrors the prop
data-rx-phone-country<select>The country picker
data-rx-phone-input<input>The number field
data-rx-phone-valuehidden inputThe E.164 value a form posts
data-rx-phone-validitymessageThe showValidity message

Every recipe below is transcribed from src/__tests__/form.browser.test.tsx, so it is code that runs on every commit. What a form stores is always E.164 — never the formatted display text, whose grouping changes with the country.

With a name, the component emits a hidden input carrying the E.164 value:

<form action="/profile" method="post">
<PhoneInput name="phone" label="Phone" defaultCountry="GB" />
<button type="submit">Save</button>
</form>

formData.get('phone') is '+442071234567', not '020 7123 4567'.

onChange takes a second argument — the PhoneDetails object — so it cannot be passed to field.onChange by reference. Forward the first argument:

<Controller
name="phone"
control={control}
render={({ field }) => (
<PhoneInput
label="Phone"
value={field.value}
onChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
)}
/>

To validate on possibility, read the second argument: onChange={(value, details) => { … }} gives you details.possible and details.country alongside the value.

function PhoneField() {
const [field, , helpers] = useField<string>('phone')
return (
<PhoneInput
label="Phone"
name="phone"
value={field.value}
onChange={(value) => helpers.setValue(value)}
onBlur={() => helpers.setTouched(true)}
/>
)
}

onBlur fires when focus leaves the whole field, not when it moves between the country select and the number box — so picking a country does not mark the field touched before a digit is typed.

<Field name="phone">
{({ input }) => (
<PhoneInput
label="Phone"
name={input.name}
value={String(input.value)}
onChange={(value) => input.onChange(value)}
onBlur={input.onBlur}
/>
)}
</Field>
<form.Field name="phone">
{(field) => (
<PhoneInput
label="Phone"
name="phone"
value={field.state.value}
onChange={(value) => field.handleChange(value)}
onBlur={field.handleBlur}
/>
)}
</form.Field>

The country selector and number input are one field. These recipes preserve that focus and blur boundary while using the design system for labels, help text, and errors.

shadcn/ui

        'use client'

import { PhoneField } from '@/components/rxova/phone-field'

export function ShadcnPhone() {
  return (
    <PhoneField
      label="Phone number"
      description="Include a country calling code."
      name="phone"
      defaultCountry="US"
    />
  )
}
      

Radix Themes

        'use client'

import { Box, Text } from '@radix-ui/themes'
import { PhoneInput } from '@rxova/react-phone-input'

export function RadixPhone() {
  return (
    <Box>
      <Text as="div" size="2" weight="bold" mb="1">
        Phone number
      </Text>
      <PhoneInput label="Phone number" name="phone" defaultCountry="US" />
      <Text as="div" size="1" color="gray" mt="1">
        Include a country calling code.
      </Text>
    </Box>
  )
}
      

Material UI

        'use client'

import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { PhoneInput } from '@rxova/react-phone-input'

export function MuiPhone() {
  return (
    <FormControl>
      <FormLabel>Phone number</FormLabel>
      <PhoneInput label="Phone number" name="phone" defaultCountry="US" />
      <FormHelperText>Include a country calling code.</FormHelperText>
    </FormControl>
  )
}
      

Chakra UI

        'use client'

import { Field } from '@chakra-ui/react'
import { PhoneInput } from '@rxova/react-phone-input'

export function ChakraPhone() {
  return (
    <Field.Root>
      <Field.Label>Phone number</Field.Label>
      <PhoneInput label="Phone number" name="phone" defaultCountry="US" />
      <Field.HelperText>Include a country calling code.</Field.HelperText>
    </Field.Root>
  )
}
      

Mantine

        'use client'

import { Input } from '@mantine/core'
import { PhoneInput } from '@rxova/react-phone-input'

export function MantinePhone() {
  return (
    <Input.Wrapper label="Phone number" description="Include a country calling code.">
      <PhoneInput label="Phone number" name="phone" defaultCountry="US" />
    </Input.Wrapper>
  )
}
      

Ant Design

        'use client'

import { Form } from 'antd'
import { PhoneInput } from '@rxova/react-phone-input'

export function AntPhone() {
  return (
    <Form.Item label="Phone number" extra="Include a country calling code.">
      <PhoneInput label="Phone number" name="phone" defaultCountry="US" />
    </Form.Item>
  )
}