Phone Number Input in React: Country Flag, Format Mask, libphonenumber
Build a production-ready international phone input in React — country flag picker, live format mask, and libphonenumber-js validation — with full TypeScript support.
Why Phone Inputs Are Still Painful in 2026
Phone inputs are one of those form fields that look trivial until you actually ship one to real users. An American developer types (555) 867-5309 and thinks the job is done. Then someone in Germany tries to enter +49 30 12345678, a Brazilian user pastes +55 (11) 91234-5678, and your backend blows up because it expected 10 digits and got 14.
The problem isn't the input element. It's that phone numbers are deeply regional — format, digit count, carrier prefix rules, even whether the leading zero is included or dropped all vary by country. You can't <input type='tel' /> your way out of this. You need a country selector, a format mask that adapts to the selected country, and a library that actually understands E.164 normalization.
Honestly, most teams either ship a bare text input and pray, or they bolt on react-phone-number-input without reading the docs and wonder why validation breaks on UK mobile numbers. This article builds it properly — from scratch first so you understand every moving part, then with the right libraries so you don't maintain 500 lines of regex forever.
Worth noting: the techniques here apply equally to React Hook Form, Formik, or any uncontrolled pattern. The core pieces — flag selector, format mask, libphonenumber-js — are framework-agnostic at heart.
Installing libphonenumber-js and Understanding What It Actually Does
The library you want is libphonenumber-js, a JavaScript port of Google's libphonenumber. It ships phone metadata for every country and gives you parsing, formatting, and validation in a single import. Install it alongside the flag emoji helper you'll need:
npm install libphonenumber-js
# optional but useful for flag emoji rendering
npm install country-flag-emoji-polyfilllibphonenumber-js has three bundle sizes. libphonenumber-js/max has full metadata including carrier lookup — 145 kB gzipped, not something you want in a hot path. libphonenumber-js/min drops extended metadata down to around 42 kB. libphonenumber-js/mobile is the sweet spot for form inputs: it keeps mobile-number validation and format masks but skips the landline/carrier data. Use mobile unless you specifically need landline validation.
The two functions you'll use constantly are parsePhoneNumber and AsYouType. parsePhoneNumber('+14155551234', 'US') gives you a structured object with .isValid(), .country, .nationalNumber, and .format('E.164'). AsYouType is the formatter class that you feed characters one at a time and it returns the progressively formatted string — that's what drives your live mask.
In practice, AsYouType is stateless per instance, so you instantiate it fresh on each keystroke. It sounds wasteful but the object is tiny and the operation is synchronous and sub-millisecond. Don't cache it across keystrokes or you'll get formatting artifacts when users backspace.
Building the Country Selector with Flag Emojis
Flag emojis are rendered as regional indicator symbol pairs — 🇺🇸 is U+1F1FA + U+1F1F8. Most modern OSes render them correctly, but Windows 10 before 2021 builds didn't. In 2026 that's a narrow enough edge case to ignore for most products, but if you're targeting enterprise Windows users, country-flag-emoji-polyfill swaps in PNG flags automatically.
Here's a minimal country selector component. It uses a curated list of CountryCode values from libphonenumber-js so you don't have to maintain phone-code-to-country mappings yourself:
import { CountryCode, getCountries, getCountryCallingCode } from 'libphonenumber-js/mobile';
const countryOptions = getCountries().map((code) => ({
code,
callingCode: `+${getCountryCallingCode(code)}`,
flag: String.fromCodePoint(
...code.split('').map((c) => 0x1f1e0 + c.charCodeAt(0) - 65)
),
}));
interface CountrySelectorProps {
value: CountryCode;
onChange: (country: CountryCode) => void;
}
export function CountrySelector({ value, onChange }: CountrySelectorProps) {
const selected = countryOptions.find((c) => c.code === value);
return (
<div className="relative inline-block">
<select
value={value}
onChange={(e) => onChange(e.target.value as CountryCode)}
className="appearance-none bg-transparent pl-8 pr-4 py-2 text-sm cursor-pointer focus:outline-none"
aria-label="Country calling code"
>
{countryOptions.map(({ code, callingCode, flag }) => (
<option key={code} value={code}>
{flag} {code} {callingCode}
</option>
))}
</select>
<span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-lg">
{selected?.flag}
</span>
</div>
);
}One thing people miss: native <select> on macOS and iOS renders the flag emoji natively in the dropdown, which looks great and has zero JS overhead. You only need a custom dropdown with a flag image approach if you want pixel-perfect cross-platform rendering or if you're adding search/filter. For most B2B products, the native select is the right call — save 8 kB of JS and ship faster.
Quick aside: getCountries() returns countries sorted alphabetically by country code (e.g., AC, AD, AE...). Users expect to see their common countries first. Sort US, GB, CA, AU to the top of the list with a divider — it's a 5-line array sort and it makes the UX meaningfully better.
The Live Format Mask with AsYouType
This is the part that makes the input feel premium. As the user types, the number reformats in real time — 1 becomes +1, then +1 4, then +1 (415), then +1 (415) 5, and so on. The AsYouType class from libphonenumber-js does all of this, you just have to wire it up to React state correctly.
import { AsYouType, CountryCode, parsePhoneNumber } from 'libphonenumber-js/mobile';
import { useState } from 'react';
import { CountrySelector } from './CountrySelector';
export function PhoneInput({
value,
onChange,
}: {
value: string;
onChange: (e164: string | null) => void;
}) {
const [country, setCountry] = useState<CountryCode>('US');
const [display, setDisplay] = useState('');
function handleInput(raw: string) {
// Strip everything except digits and leading +
const digits = raw.replace(/[^\d+]/g, '');
const formatter = new AsYouType(country);
const formatted = formatter.input(digits);
setDisplay(formatted);
// Attempt to parse to E.164 for the parent form
try {
const parsed = parsePhoneNumber(formatted, country);
onChange(parsed.isValid() ? parsed.format('E.164') : null);
} catch {
onChange(null);
}
}
function handleCountryChange(newCountry: CountryCode) {
setCountry(newCountry);
// Re-format existing number under the new country
if (display) {
const formatter = new AsYouType(newCountry);
setDisplay(formatter.input(display.replace(/[^\d]/g, '')));
}
}
return (
<div className="flex items-center gap-2 border rounded-xl px-3 py-2 focus-within:ring-2 focus-within:ring-violet-500">
<CountrySelector value={country} onChange={handleCountryChange} />
<span className="text-gray-400">|</span>
<input
type="tel"
inputMode="tel"
value={display}
onChange={(e) => handleInput(e.target.value)}
placeholder="(555) 867-5309"
className="flex-1 bg-transparent outline-none text-sm"
aria-label="Phone number"
/>
</div>
);
}A couple of sharp edges to watch for. First, always use inputMode="tel" in addition to type="tel" — type="tel" is ignored in some contexts (inside shadow DOM, inside certain form libraries), but inputMode always triggers the numeric dial-pad on iOS and Android. Second, the digit-stripping regex replace(/[^\d+]/g, '') needs the + exception so users can paste an E.164 number with the country code prefix — without it, the paste strips the plus and the formatter guesses wrong.
Look, there's a subtle UX trap with the handleCountryChange function: when a user changes the country after typing a partial number, you have two reasonable options — keep the digits and reformat them under the new country code, or clear the field entirely. Keeping the digits is usually better for users who are correcting a mistake. Clearing is better if your UX flow explicitly presents country first. The code above keeps the digits; adjust to taste.
That said, AsYouType will sometimes produce uncertain output mid-typing — it might show +1 415 even though the final number needs 10 more digits. Don't validate on each keystroke. Call parsePhoneNumber(...).isValid() only on blur or on form submit. Inline validation while the user is still typing +44 7911 is annoying and almost always wrong.
Validation, E.164 Output, and React Hook Form Integration
Your backend doesn't want (415) 555-0132. It wants +14155550132. E.164 is the international standard — country code, no spaces, no dashes, no parentheses. It's what Twilio, Vonage, AWS SNS, and every SMS/voice API expects. Store E.164 in your DB and format for display in the UI — never the other way around.
Integrating with React Hook Form (v7) is a two-liner using Controller:
import { Controller, useForm } from 'react-hook-form';
import { PhoneInput } from './PhoneInput';
type FormValues = { phone: string };
export function SignupForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="phone"
control={control}
rules={{
validate: (v) =>
v !== null || 'Enter a valid international phone number',
}}
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={(e164) => field.onChange(e164)}
/>
)}
/>
{errors.phone && (
<p className="text-red-500 text-xs mt-1">{errors.phone.message}</p>
)}
<button type="submit">Submit</button>
</form>
);
}The validation rule passes when e164 is a non-null string (meaning parsePhoneNumber.isValid() returned true inside the component). That way RHF's error state mirrors libphonenumber's opinion of the number — you're not duplicating regex logic in two places.
One more thing — server-side validation. Don't trust client-side validation alone. On your API endpoint, run the same libphonenumber check (Node.js can import libphonenumber-js directly) before attempting to send an SMS or store the number. The E.164 format from the client is correct in spirit but you should never assume the client code ran at all.
For Zod users, a one-liner schema validator works cleanly: z.string().refine((v) => { try { return parsePhoneNumber(v).isValid(); } catch { return false; } }, { message: 'Invalid phone number' }). Drop that into your tRPC or Next.js API route and you're covered end-to-end.
Accessibility, Mobile UX, and the 48px Tap Target Rule
The country selector is the accessibility weak point of every phone input I've reviewed. A <select> with 240+ options labeled as country codes (AC, AD...) is navigable by screen readers but genuinely slow for keyboard users. Add a <label> with a for attribute pointing to the select's id, and consider adding an aria-describedby on the phone <input> that references a visible hint like "Include your country code".
Mobile tap target size: WCAG 2.5.5 (AAA) recommends 44x44 px, and Apple's HIG specifies 44pt minimum. In practice, 48px is the Google Material baseline and what most teams aim for. Your flag/select combo needs min-h-[48px] on its container, not just the text inside. This is especially important on the flag button — it's a small tap surface and users will mis-tap it on phones with broken digitizers or large fingers.
The inputMode="tel" attribute triggers the phone dial-pad keyboard on iOS (since iOS 12.2) and Android. But here's the trap: if your input already contains formatted text like (415) 555, iOS sometimes switches back to the text keyboard mid-session. Adding pattern="[0-9+\s\-().]*" helps hint to the browser that this is still a phone field throughout the session.
If you're building a form-heavy product, Empire UI's component library includes pre-built input variants with correct ARIA roles and focus ring styles out of the box — saves you from re-auditing accessibility on every new field type. Pair it with the box shadow generator if you want to fine-tune the focus ring shadow to match your design system.
react-phone-number-input vs Building Your Own
There's a well-maintained library called react-phone-number-input (authored by @catamphetamine) that wraps most of this for you. It's been around since 2017, it's 38 kB min+gzipped with the metadata bundle, and it uses libphonenumber-js under the hood. If your product ships one phone input and you don't need deep UI customization, it's a solid choice. Install it and you're done in 15 minutes.
npm install react-phone-number-input
```
```tsx
import PhoneInput from 'react-phone-number-input';
import 'react-phone-number-input/style.css';
<PhoneInput
international
defaultCountry="US"
value={phone}
onChange={setPhone}
/>The downside: the default CSS is opinionated and requires overrides to match any design system that isn't the library's own. The flag images are fetched as external PNGs from a CDN unless you configure the local flag set — which adds another install step. And the component API is not compatible with Radix UI's asChild pattern, so if you're composing it into a Radix Form.Field, you'll hit friction.
Building your own as shown in this article gives you full control: your design tokens, your Tailwind classes, your focus ring, your error state styles. It's maybe 100 lines of component code once you understand the pieces, and you won't spend an afternoon fighting CSS specificity wars with a third-party stylesheet. For a design-conscious product — something using expressive UI styles from Empire UI, say — owning the component is worth the 45 minutes.
That said, if you just need something working by end of day and you're not in a design system context, react-phone-number-input plus a quick CSS override session is totally valid. Don't let perfect be the enemy of shipped.
FAQ
They trade bundle size for metadata coverage. /mobile (~42 kB) validates mobile numbers and drives format masks — right for most form inputs. /min is similar but includes landline patterns. /max adds carrier lookups and runs about 145 kB gzipped; only use it if you need carrier-level validation server-side.
Always store E.164 format — e.g. +14155551234. It's the international standard expected by every SMS/voice API (Twilio, AWS SNS, Vonage). Format it for display in the UI using parsePhoneNumber(e164).formatNational() for the user's country or formatInternational() for cross-border display.
On blur (or form submit), not on each keystroke. A user typing +44 7911 mid-entry will trigger invalid-number errors constantly, which is annoying and misleading. Validate once they leave the field. You can show a subtle green checkmark on keystrokes when isValid() returns true without showing an error when it returns false.
Yes, via the Controller component. Wrap PhoneInput inside a Controller render prop and pass field.value / field.onChange through. One caveat: you need to register the field as a string (E.164) not an object, so make sure your defaultValues initializes it as an empty string and your validation rule handles the undefined case.