Phone input — About
The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.
Possible, not valid
Section titled “Possible, not valid”The single most important thing to understand about this package.
| Possibility (what this does) | Full validity | |
|---|---|---|
| Question answered | Is this a length the country uses? | Is this an assignable number? |
+1 555 555 5555 | possible | not valid |
| Data needed | dial codes and lengths (~4 kB) | carrier assignment rules |
| Good for | stopping a typo in a form | auditing 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 usesparsePhone('+4420712').possible // false — no UK number is 5 digitsparsePhone('+9912345678').possible // false — no such calling codeThat 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.
Where the data comes from
Section titled “Where the data comes from”| What | Source | Cost |
|---|---|---|
| Country names | Intl.DisplayNames | 0 — every engine ships ICU |
| Flags | Two Unicode regional-indicator letters | 0 — six bytes of arithmetic |
| Calling codes, lengths, grouping | This 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.
Shared calling codes
Section titled “Shared calling codes”+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 first — 350 (Gibraltar) and 351 (Portugal) both start
with 35, so a shortest-first scan would have to guess.
Trunk prefixes
Section titled “Trunk prefixes”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.
The caret
Section titled “The caret”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:
- Before the reformat, count how many digits precede the caret.
- 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.
Native controls
Section titled “Native controls”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, andhideCountrySelectplus the headless hook lets you build your own.<input type="tel">, nottype="number".numberstrips leading zeros, offers a spinner nobody wants on a phone number, and refuses+entirely.inputmode="tel"gives the dial pad andautocomplete="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.
The length cap
Section titled “The length cap”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:
| Pasted | Characters |
|---|---|
+1 (415) 555-2671 | 17 |
+44 (0)20 7123 4567 | 20 |
0044 (0)20 7123 4567 | 20 |
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.
Diagnostics
Section titled “Diagnostics”onWarn receives { code, prop, received, message } whenever a prop is rejected or coerced.
| Code | Meaning |
|---|---|
unknown-country | An ISO code not in the table, in country or countries |
unknown-default-country | Same, in defaultCountry |
value-not-e164 | A national number passed where E.164 belongs |
value-country-unknown | A + value whose calling code is not in the table |
empty-country-list | countries was empty; the full list is used |
locale-invalid | Intl refused the tag; names fall back to ISO codes |
max-length-too-small | maxLength 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.
Known limitations
Section titled “Known limitations”- 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-6grouping formats a mobile (07911 123456) correctly and a London landline as2071 234567rather than20 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.
Styling
Section titled “Styling”There is no stylesheet to import.
Custom properties
Section titled “Custom properties”Set them on [data-rx-phone-root] or any ancestor:
[data-rx-phone-root] { --rx-phone-gap: 0.375rem; --rx-phone-select-width: 9rem;}Stable data-* hooks
Section titled “Stable data-* hooks”These are public API, covered by semver.
| Attribute | On | Meaning |
|---|---|---|
data-rx-phone-root | wrapper | Always present |
data-country | wrapper | ISO code of the resolved country |
data-possible | wrapper | The number is a possible length |
data-invalid / data-disabled | wrapper | Mirrors the props |
data-readonly | wrapper | Mirrors the prop |
data-rx-phone-country | <select> | The country picker |
data-rx-phone-input | <input> | The number field |
data-rx-phone-value | hidden input | The E.164 value a form posts |
data-rx-phone-validity | message | The showValidity message |
Form libraries
Section titled “Form libraries”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.
Native forms
Section titled “Native forms”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'.
React Hook Form
Section titled “React Hook Form”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.
Formik
Section titled “Formik”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.
React Final Form
Section titled “React Final Form”<Field name="phone"> {({ input }) => ( <PhoneInput label="Phone" name={input.name} value={String(input.value)} onChange={(value) => input.onChange(value)} onBlur={input.onBlur} /> )}</Field>TanStack Form
Section titled “TanStack Form”<form.Field name="phone"> {(field) => ( <PhoneInput label="Phone" name="phone" value={field.state.value} onChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> )}</form.Field>UI-library recipes
Section titled “UI-library recipes”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>
)
}