eu-phone-input
v0.1.3
Published
React phone input component for EU, UK, Norway, and Switzerland numbers with TypeScript, formatting, and validation
Maintainers
Readme
eu-phone-input
A React phone number input component covering all 27 EU member states plus UK, Norway, and Switzerland (30 countries). Works in Vite, Next.js, Remix, Astro with React, and other React apps. Formats numbers as you type, validates length, and handles international input.
Features
- 🇪🇺 All 27 EU member states + UK, Norway, Switzerland (30 countries total)
- 🏳️ Real flag images (no emoji rendering issues on Windows)
- ✍️ Format as you type —
0831234567→083 123 4567 - 📋 Smart international input —
+353831234567or00353831234567switches country and strips the calling code automatically - 🔍 Country search + keyboard navigation in the dropdown
- 🌙 Dark mode out of the box
- 🎨 Fully themeable with CSS variables
- 🪝 Headless hook (
usePhoneInput) for custom markup - 📦 Zero runtime dependencies (React peer dep only)
- ⚛️ Works with Vite, Next.js, Remix, Astro with React, and plain React apps
- TypeScript first
Installation
npm install eu-phone-inputCompatibility
eu-phone-input is a React package, not a Next.js-only package. It supports React 18+
and can be used anywhere your app can import React components and CSS.
The PhoneInput component includes "use client" so it works cleanly in the
Next.js App Router. Other React build tools safely ignore that directive.
Quick start
import { PhoneInput } from 'eu-phone-input';
import 'eu-phone-input/style.css';
export default function ContactForm() {
return (
<PhoneInput
defaultCountry="IE"
showValidation
onChange={(value, meta) => {
console.log(meta.e164); // "+353831234567"
console.log(meta.valid); // true
console.log(meta.national); // "083 123 4567"
console.log(meta.country); // { iso2: "IE", name: "Ireland", ... }
}}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultCountry | string | "IE" | ISO2 country code shown on mount |
| value | string | — | Controlled input value |
| showValidation | boolean | false | Show ✓ / ✗ icon next to input |
| onChange | (value, meta) => void | — | Fires on every keystroke |
| onValidated | (meta) => void | — | Fires on blur when number is valid |
| placeholder | string | country default | Override placeholder text |
| disabled | boolean | false | Disable the input |
| className | string | — | Extra class added to the wrapper element |
| renderFlag | (country) => React.ReactNode | — | Override default flag images |
| name | string | — | HTML name attribute on the <input> |
| id | string | — | HTML id attribute on the <input> |
The meta object
Both onChange and onValidated receive a PhoneMeta object:
interface PhoneMeta {
valid: boolean;
e164: string | null; // "+353831234567"
national: string | null; // "083 123 4567"
international: string | null; // "+353 83 123 4567"
country: CountryData | null;
raw: string; // exactly what was in the input
}Styling
By default, country flags are loaded from https://flagcdn.com. If your app has
a strict Content Security Policy or needs to avoid third-party image requests,
pass renderFlag to render your own local flag asset, emoji, or text label.
Option 1 — CSS variables (recommended)
Override any variable globally or scoped to one instance:
/* globals.css — applies everywhere */
:root {
--ipi-border: #6366f1;
--ipi-focus-color: #6366f1;
--ipi-focus-ring: rgba(99, 102, 241, 0.2);
--ipi-radius: 8px;
--ipi-font-size: 15px;
}/* Scoped to a single component using className="checkout-phone" */
.checkout-phone {
--ipi-border: #d97706;
--ipi-focus-color: #d97706;
}<PhoneInput className="checkout-phone" defaultCountry="IE" />Full list of CSS variables:
| Variable | Default | Controls |
|---|---|---|
| --ipi-border | #d1d5db | Default border colour |
| --ipi-bg | #ffffff | Input background |
| --ipi-btn-bg | #f9fafb | Country button background |
| --ipi-text | #111827 | Text colour |
| --ipi-muted | #6b7280 | Placeholder / secondary text |
| --ipi-radius | 6px | Border radius |
| --ipi-font-size | 14px | Font size |
| --ipi-focus-color | #3b82f6 | Border colour on focus |
| --ipi-focus-ring | rgba(59,130,246,.15) | Glow shadow on focus |
| --ipi-valid-color | #22c55e | Border colour when valid |
| --ipi-valid-ring | rgba(34,197,94,.15) | Glow shadow when valid |
| --ipi-invalid-color | #ef4444 | Border colour when invalid |
| --ipi-invalid-ring | rgba(239,68,68,.15) | Glow shadow when invalid |
Option 2 — Target .ipi-* classes directly
All internal elements have stable class names you can override in your stylesheet.
Import your CSS after eu-phone-input/style.css so specificity works normally:
/* Make the dropdown wider */
.ipi-dropdown { min-width: 320px; }
/* Bigger country button */
.ipi-country-btn { padding: 10px 14px; }
/* Monospace input */
.ipi-input { font-family: monospace; }Class reference:
| Class | Element |
|---|---|
| .ipi-wrapper | Outer container |
| .ipi-country-btn | Flag + calling code button |
| .ipi-flag | Flag <img> |
| .ipi-calling-code | +353 text next to flag |
| .ipi-chevron | Dropdown arrow ▾ |
| .ipi-input | Phone number <input> |
| .ipi-status | ✓ / ✗ validation icon |
| .ipi-dropdown | Country list panel |
| .ipi-search-wrap | Search input wrapper |
| .ipi-search | Search <input> inside dropdown |
| .ipi-dropdown-item | Each country row |
| .ipi-selected | Currently selected country row |
| .ipi-focused | Keyboard-focused country row |
| .ipi-no-results | "No countries found" message |
Option 3 — Headless (bring your own UI)
Use usePhoneInput directly and skip the built-in markup and CSS entirely.
This is the right choice if you're using Tailwind or a design system:
import { usePhoneInput } from 'eu-phone-input';
// No style.css import needed
export function MyPhoneInput() {
const {
inputValue,
selectedCountry,
meta,
isDropdownOpen,
filteredCountries,
focusedIndex,
search,
searchInputRef,
handleInputChange,
handleCountrySelect,
handleInputBlur,
handleSearch,
handleKeyDown,
setIsDropdownOpen,
} = usePhoneInput({ defaultCountry: 'IE' });
return (
<div className="flex border rounded-lg" onKeyDown={handleKeyDown}>
{/* Country picker button */}
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center gap-2 px-3 border-r"
>
{selectedCountry.flag} +{selectedCountry.callingCode}
</button>
{/* Dropdown */}
{isDropdownOpen && (
<div className="absolute top-full mt-1 bg-white border rounded shadow-lg z-50 w-64">
<input
ref={searchInputRef}
value={search}
onChange={handleSearch}
placeholder="Search…"
className="w-full p-2 border-b outline-none"
/>
{filteredCountries.map((c, i) => (
<button
key={c.iso2}
onClick={() => handleCountrySelect(c)}
className={`flex gap-2 w-full px-3 py-2 text-left ${
i === focusedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
{c.flag} {c.name}
<span className="ml-auto text-gray-400">+{c.callingCode}</span>
</button>
))}
</div>
)}
{/* Phone input */}
<input
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
type="tel"
className="flex-1 px-3 outline-none"
placeholder="083 123 4567"
/>
{meta.valid && <span className="text-green-500 px-2 self-center">✓</span>}
</div>
);
}Utility functions
The core parsing logic is exported for use outside the component — useful for server-side validation or custom flows:
import { parsePhone, toE164, formatNational, digitsOnly, getCountry } from 'eu-phone-input';
parsePhone('0831234567', 'IE');
// { valid: true, e164: '+353831234567', national: '083 123 4567', ... }
parsePhone('+353831234567');
// Same result — calling code auto-detected
toE164('0831234567', getCountry('IE')!);
// "+353831234567"
formatNational('07911123456', 'GB');
// "07911 123456"
digitsOnly('+353 083 123 4567');
// "3530831234567"Country data
import { COUNTRIES, getCountry, DEFAULT_COUNTRY } from 'eu-phone-input';
getCountry('IE');
// {
// iso2: "IE",
// name: "Ireland",
// callingCode: "353",
// flag: "🇮🇪",
// minLength: 8,
// maxLength: 9,
// leadingZeroStripped: true
// }minLength and maxLength are the digit count without the leading zero.
leadingZeroStripped: true means numbers in local format start with 0 (e.g. 083...)
but that 0 is dropped in E.164 format (+353 83...).
All 27 EU member states:
| Country | ISO2 | Code | |---|---|---| | Austria | AT | +43 | | Belgium | BE | +32 | | Bulgaria | BG | +359 | | Croatia | HR | +385 | | Cyprus | CY | +357 | | Czech Republic | CZ | +420 | | Denmark | DK | +45 | | Estonia | EE | +372 | | Finland | FI | +358 | | France | FR | +33 | | Germany | DE | +49 | | Greece | GR | +30 | | Hungary | HU | +36 | | Ireland | IE | +353 | | Italy | IT | +39 | | Latvia | LV | +371 | | Lithuania | LT | +370 | | Luxembourg | LU | +352 | | Malta | MT | +356 | | Netherlands | NL | +31 | | Poland | PL | +48 | | Portugal | PT | +351 | | Romania | RO | +40 | | Slovakia | SK | +421 | | Slovenia | SI | +386 | | Spain | ES | +34 | | Sweden | SE | +46 |
Plus (non-EU European):
| Country | ISO2 | Code | |---|---|---| | Norway | NO | +47 | | Switzerland | CH | +41 | | United Kingdom | GB | +44 |
Validation behaviour
| Input | defaultCountry | Result |
|---|---|---|
| +353831234567 | any | ✅ Detected from calling code |
| 00353831234567 | any | ✅ Detected from 00 prefix |
| 0831234567 | IE | ✅ Local format matched |
| 831234567 | IE | ✅ Missing leading zero added |
| 353831234567 | IE | ✅ Calling code stripped |
| 35699123456 | IE | ❌ Treated as local/ambiguous unless entered as +35699123456 or 0035699123456 |
| +3530831234567 | any | ✅ Accepted — extra 0 normalised |
| 0831234567 | none | ❌ Ambiguous without hint |
The selected country is used as a hint for local-looking numbers. To auto-switch
countries from the phone input, enter the number in international format with
+ or 00, or pick the country from the dropdown first. Bare calling codes such
as 356... are not auto-detected because they can overlap with local numbering.
License
MIT
