@rxova/react-phone-input
v1.0.3
Published
International phone input with no metadata blob: country names from Intl, flags from Unicode, 4 kB of dial codes. Headless, zero-dependency.
Readme
International phone input with no metadata blob. Country names from Intl, flags from Unicode,
4 kB of dial codes. Headless, zero-dependency.
npm install @rxova/react-phone-input📖 Documentation & live examples → — guides, form recipes, theming, the possible-versus-valid distinction, and migration from another phone library.
- 10x smaller than the default choice. 4.5 kB brotli against
react-phone-number-input's 41.1 kB: names come fromIntl.DisplayNames, flags are two regional-indicator letters, and all that is left to ship is the dial-code table. - No flag sprite sheet, no icon font. A flag emoji is six bytes of arithmetic.
- E.164 in and out — one canonical format, never the formatted display text.
- As-you-type formatting that keeps your caret where you left it, mid-string.
- Native
<select>and native<input type="tel">— the platform's own picker on mobile, autofill, and keyboard type-ahead for free. - Zero runtime dependencies, 4.5 kB brotli, no stylesheet to import.
Basic use
import { useState } from "react";
import { PhoneInput } from "@rxova/react-phone-input";
function Signup() {
const [phone, setPhone] = useState("");
return <PhoneInput label="Phone number" value={phone} onChange={setPhone} defaultCountry="GB" />;
}onChange also receives the details:
import { PhoneInput } from "@rxova/react-phone-input";
function Field() {
return (
<PhoneInput
label="Phone"
onChange={(value, details) => {
// value '+442071234567'
// details { e164, country: 'GB', national: '2071234567', possible: true }
console.log(value, details);
}}
/>
);
}Possible, not valid
The claim this package makes is possibility, and the prop is named possible rather than
valid on purpose.
libphonenumber can tell you +1 555 555 5555 is not an assignable US number, because it carries
the assignment rules for every carrier block in the world. That is what its metadata buys, and it
is genuinely worth its ~40 kB if you are validating a database of numbers.
This tells you the digits are a length that country actually uses, which is what a form field needs to stop a typo — and it costs 4 kB. If you need real validity, validate on the server, where the metadata is free and the answer can be trusted.
import { isPossible, countryByISO2, 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
isPossible(countryByISO2("US"), "4155552671"); // trueWhere 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 |
That is the whole trick. Bundled locale JSON in this space is mostly names, and the platform has been giving those away since 2019.
import { countryName, flagEmoji } from "@rxova/react-phone-input";
countryName("DE", "en"); // 'Germany'
countryName("DE", "fr"); // 'Allemagne'
flagEmoji("DE"); // '🇩🇪'On Windows the flag renders as the two letters instead of a flag. That is a legible fallback, not a broken image.
Input the component accepts
Users paste all three of these, so all three work:
| Typed | Read as |
| ------------------- | ------------------------------------------------------------------------------------------ |
| +44 20 7123 4567 | Explicit international. The calling code decides the country; defaultCountry is ignored. |
| 0044 20 7123 4567 | The 00 international prefix, treated identically. |
| 020 7123 4567 | National, against the selected country. The trunk 0 is dropped. |
011 is also honoured as the international prefix when the selected country is in the NANP — but
only there, because 011x is a legitimate UK area code.
Non-Latin digits are normalised: Arabic-Indic, extended Arabic-Indic and full-width numerals all work, so a user typing on their own keyboard layout is not silently ignored.
The length cap
The field accepts at most 32 characters, and the cap cannot be removed — only moved, with
maxLength.
An unbounded phone field is a denial-of-service surface: every keystroke re-parses, re-groups and
re-formats the whole contents, and nothing bounds that except the size of what was pasted. So there
is always a cap, exactly as there always is in
@rxova/react-password-input.
32 is not arbitrary. E.164 caps a number at 15 digits, so the longest text this field can ever
produce is 21 characters — a +, the calling code and the grouped digits — which the adversarial
suite asserts against the country table itself. 32 is half again as much, which is the room
people's own punctuation needs:
| Pasted | Characters |
| ---------------------- | ---------- |
| +1 (415) 555-2671 | 17 |
| +44 (0)20 7123 4567 | 20 |
| 0044 (0)20 7123 4567 | 20 |
Nothing written the way people write phone numbers comes close to 32, so no real number is ever truncated — but a pasted file stops at 32 characters instead of reaching the parser.
The cap applies to the text in the box, after grouping, not to the raw keystrokes: maxLength also
lands on the <input>, and usePhoneInput enforces it as well, so a programmatic paste and a
headless renderer are bounded too. A maxLength below 21 could truncate a number the component
had just formatted, so it is refused — the default is used instead and onWarn reports
max-length-too-small.
Restricting the list
import { PhoneInput } from "@rxova/react-phone-input";
function Field() {
return (
<PhoneInput label="Phone" countries={["GB", "IE", "FR", "DE", "ES"]} defaultCountry="GB" />
);
}Order is preserved. Unknown codes are dropped and reported through onWarn; an entirely empty or
entirely unknown list falls back to the full table, because a picker with nothing in it is not a
usable field.
Forms
With a name, the component emits a hidden input carrying the E.164 value, so a native form posts
the canonical number rather than the formatted display text:
import { PhoneInput } from "@rxova/react-phone-input";
function Form() {
return (
<form action="/signup" method="post">
<PhoneInput label="Phone" name="phone" defaultCountry="US" />
<button type="submit">Sign up</button>
</form>
);
}The visible box shows 415 555 2671; the form receives +14155552671.
React Hook Form, Formik and the rest work through value/onChange as usual, and the ref lands
on the <input>, which is what setFocus() and focus-first-error patterns expect.
Styling
There is no stylesheet to import.
| Property | Default | Applies to |
| ------------------------- | ---------- | ------------------------------------ |
| --rx-phone-gap | 0.375rem | Space between the select and the box |
| --rx-phone-select-width | 9rem | Maximum width of the country select |
data-* attributes
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-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 |
Headless
usePhoneInput gives you the whole state machine with no markup — including the caret bookkeeping,
which is the part worth not rewriting.
import { usePhoneInput } from "@rxova/react-phone-input";
function CustomField() {
const field = usePhoneInput({ defaultCountry: "GB" });
return (
<div onBlur={field.handleBlur}>
<select
value={field.country?.iso2}
onChange={(event) => {
field.selectCountry(event.target.value);
}}
>
{field.countries.map((country) => (
<option key={country.iso2} value={country.iso2}>
{field.flagFor(country.iso2)} {field.nameFor(country.iso2)} +{country.dial}
</option>
))}
</select>
<input
ref={(node) => {
field.inputRef.current = node;
}}
type="tel"
value={field.text}
onChange={field.handleInputChange}
/>
</div>
);
}The parsing helpers are exported too — parsePhone, formatPhone, formatNational, isPossible,
digitsOnly, lengthsFor — all pure and synchronous.
Diagnostics
onWarn receives a { code, prop, received, message } whenever a prop is rejected or coerced:
import * as Sentry from "@sentry/react";
import { PhoneInput } from "@rxova/react-phone-input";
function Field() {
return (
<PhoneInput
label="Phone"
onWarn={(warning) => {
Sentry.captureMessage(warning.message, { level: "warning", extra: { ...warning } });
}}
/>
);
}Codes: unknown-country, unknown-default-country, value-not-e164, value-country-unknown,
empty-country-list, locale-invalid, max-length-too-small.
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.
Accessibility
- A real
<input type="tel">withinputmode="tel"andautocomplete="tel", beside a real<select>.labelnames it; supply your own<label htmlFor={${id}-input}>when the design wants visible text. Both are native controls on purpose: on a phone the select is the platform's own picker, already searchable and localised, which no custom listbox of 234 options matches. - The select carries an accessible name of its own (
countryLabel), so it is not announced as an unlabelled combobox. - Every option has a name — flag, country name and calling code — so keyboard type-ahead works and nothing is announced blank.
- The hidden E.164 field is
type="hidden", so it is never focusable and never announced; the visible controls are the accessible representation of the value. - 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.
UI-library recipes
The docs contain maintained examples for shadcn/ui, Radix Themes, Material UI, Chakra UI, Mantine and Ant Design. Copy a ready-labelled wrapper with:
npx shadcn@latest add https://rxova.dev/packages/react-inputs/r/phone-field.jsonPart of rxova
Part of the rxova headless React inputs suite —
install the whole set from
@rxova/react-inputs, or read the full
generated API reference for this package.
Cross-cutting guidance lives on this component's About page: styling and form libraries. Coming from another library? The migration guide maps the props across.
License
MIT © rxova
