@eroyeroy/react-phone-mask
v1.0.0
Published
React phone input toolkit with ready UI, headless hook, country masks, formatting, validation and SSR-friendly API.
Maintainers
Readme
@eroyeroy/react-phone-mask
Production-ready React phone input toolkit with country-aware masks, live formatting, validation metadata, E.164 output, accessible country picker, headless hook and pure utility functions.
The package is intentionally split into three usage layers:
- UI component — use
PhoneInputwhen you want a ready styled input. - Headless React hook — use
usePhoneInputwhen you want your own markup or design-system input. - Core utilities — use pure functions when React rendering is not needed.
Features
- Ready-to-use
PhoneInputcomponent - Headless
usePhoneInputhook for custom inputs - Pure formatting, parsing and normalization utilities
- TypeScript-first public API
- Country-aware phone masks powered by
libphonenumber-js - E.164 output for backend APIs
isPossibleandisValidvalidation flags- Controlled and uncontrolled value/country modes
- Full country list and custom country subsets
- Single-country mode without an interactive selector
- Searchable and keyboard-friendly country picker
- Optional country list virtualization
- Custom flag renderer
- CSS variables and class name overrides
- SSR-friendly ESM and CJS package output
Installation
npm:
npm install @eroyeroy/react-phone-maskyarn:
yarn add @eroyeroy/react-phone-maskpnpm:
pnpm add @eroyeroy/react-phone-maskbun:
bun add @eroyeroy/react-phone-mask1. Ready-to-use component
Import the component and the stylesheet once in your app.
import { PhoneInput } from "@eroyeroy/react-phone-mask";
import "@eroyeroy/react-phone-mask/styles.css";
export function Form() {
return (
<PhoneInput
defaultCountry="RU"
label="Phone number"
onValueChange={(data) => {
console.log(data.formattedValue);
console.log(data.e164);
console.log(data.isValid);
}}
/>
);
}Controlled component usage
For controlled mode, store the formatted value returned from onValueChange.
import { useState } from "react";
import {
PhoneInput,
type PhoneCountryCode,
type PhoneInputValueChangeData,
} from "@eroyeroy/react-phone-mask";
import "@eroyeroy/react-phone-mask/styles.css";
export function ControlledPhoneField() {
const [value, setValue] = useState("");
const [country, setCountry] = useState<PhoneCountryCode>("RU");
const [phoneData, setPhoneData] =
useState<PhoneInputValueChangeData | null>(null);
return (
<PhoneInput
value={value}
country={country}
label="Phone number"
error={value && !phoneData?.isPossible ? "Enter a valid phone" : undefined}
onCountryChange={(nextCountry) => {
setCountry(nextCountry);
}}
onValueChange={(data) => {
setValue(data.formattedValue);
setPhoneData(data);
}}
/>
);
}PhoneInput owns the native input onChange handler internally so it can format the value and preserve the caret position. Use onValueChange for form state.
2. Headless usage with your own input
Use usePhoneInput when you want to render your own input, selector, layout or design-system component.
import {
getPhoneCountries,
usePhoneInput,
type PhoneCountryCode,
} from "@eroyeroy/react-phone-mask";
const countries = getPhoneCountries({
countries: ["RU", "US", "DE", "FR"],
locale: "en",
});
export function CustomPhoneField() {
const phone = usePhoneInput({
defaultCountry: "RU",
onValueChange: (data) => {
console.log(data.e164);
},
});
return (
<div className="phone-field">
<select
value={phone.country}
onChange={(event) => {
phone.setCountry(event.target.value as PhoneCountryCode);
}}
>
{countries.map((country) => (
<option key={country.code} value={country.code}>
{country.name} +{country.callingCode}
</option>
))}
</select>
<input
ref={phone.inputRef}
{...phone.inputProps}
className="my-input"
/>
</div>
);
}inputRef is important for full headless behavior: it lets the hook restore the caret after every formatting update. If your design-system input uses a different ref prop, forward phone.inputRef to the underlying native <input>.
Headless API
type UsePhoneInputReturn = {
value: string;
country: PhoneCountryCode;
data: PhoneInputValueChangeData;
setValue: (value: string) => PhoneInputValueChangeData;
setCountry: (country: PhoneCountryCode) => PhoneInputValueChangeData;
inputRef: RefObject<HTMLInputElement | null>;
inputProps: {
id?: string;
name?: string;
value: string;
type: "tel";
inputMode: "tel";
autoComplete: "tel";
disabled?: boolean;
required?: boolean;
onChange: ChangeEventHandler<HTMLInputElement>;
};
};3. Pure core utilities
Use these helpers in validation schemas, submit handlers, server actions, tests or non-React code.
import {
formatPhoneInput,
getPhoneInputValueData,
normalizePhoneDigits,
parsePhoneInput,
} from "@eroyeroy/react-phone-mask";
const formatted = formatPhoneInput("9991234567", {
country: "RU",
});
const parsed = parsePhoneInput(formatted, "RU");
const data = getPhoneInputValueData("9991234567", {
country: "RU",
});
console.log(formatted); // "999 123-45-67"
console.log(parsed.e164); // "+79991234567"
console.log(data.isValid); // boolean
console.log(normalizePhoneDigits("+7 (999) 123-45-67")); // "79991234567"Returned value metadata
onValueChange, usePhoneInput().data and getPhoneInputValueData() return this shape:
type PhoneInputValueChangeData = {
country: PhoneCountryCode;
callingCode: string;
rawValue: string;
digits: string;
nationalNumber: string;
e164: string | null;
international: string;
isPossible: boolean;
isValid: boolean;
formattedValue: string;
};Example:
{
"country": "RU",
"callingCode": "7",
"rawValue": "999 123-45-67",
"digits": "9991234567",
"nationalNumber": "9991234567",
"e164": "+79991234567",
"international": "+7 999 123 45 67",
"isPossible": true,
"isValid": true,
"formattedValue": "999 123-45-67"
}Use isPossible for live UI feedback and isValid for stricter submit-time validation.
Country helpers
import {
getPhoneCountries,
getPhoneCountry,
getPhoneCountryCallingCode,
getPhoneCountryFlag,
getPhoneCountryName,
getPhoneExample,
getPhoneNationalDigitsLimit,
} from "@eroyeroy/react-phone-mask";
const countries = getPhoneCountries({ locale: "ru" });
const ru = getPhoneCountry("RU", "ru");
const placeholder = getPhoneExample("RU");type PhoneCountry = {
code: PhoneCountryCode;
name: string;
callingCode: string;
flag: string;
};Single-country mode
Hide the interactive country selector while keeping the calling code visible.
<PhoneInput
defaultCountry="RU"
withCountrySelect={false}
label="Russian phone"
/>Custom country list
<PhoneInput
defaultCountry="US"
countries={["US", "CA", "GB", "DE", "FR"]}
label="Phone number"
/>Virtualized country list
Enable virtualization for large lists or custom flag images.
<PhoneInput
defaultCountry="RU"
virtualizeCountryList
label="Phone number"
/>Custom flags
import type { PhoneCountry } from "@eroyeroy/react-phone-mask";
function renderFlag(country: PhoneCountry) {
return (
<img
alt=""
src={`https://flagcdn.com/w40/${country.code.toLowerCase()}.png`}
width={20}
height={15}
loading="lazy"
/>
);
}
export function App() {
return (
<PhoneInput
defaultCountry="RU"
renderFlag={renderFlag}
virtualizeCountryList
/>
);
}Styling
Import the default stylesheet:
import "@eroyeroy/react-phone-mask/styles.css";Customize the component with CSS variables:
.rpm-root {
--rpm-background: #ffffff;
--rpm-text-color: #0f172a;
--rpm-muted-text-color: #64748b;
--rpm-border-color: #cbd5e1;
--rpm-border-color-hover: #94a3b8;
--rpm-border-color-focus: #2563eb;
--rpm-border-color-error: #dc2626;
--rpm-disabled-background: #f8fafc;
--rpm-disabled-text-color: #94a3b8;
--rpm-radius: 12px;
--rpm-height: 44px;
--rpm-font-size: 14px;
--rpm-label-font-size: 14px;
--rpm-message-font-size: 13px;
}Example dark variant:
.my-phone-input {
--rpm-background: #020617;
--rpm-text-color: #e5e7eb;
--rpm-muted-text-color: #94a3b8;
--rpm-border-color: #334155;
--rpm-border-color-hover: #475569;
--rpm-border-color-focus: #60a5fa;
}Class names
<PhoneInput
defaultCountry="RU"
className="phone"
classNames={{
label: "phone__label",
control: "phone__control",
input: "phone__input",
helperText: "phone__helper",
errorText: "phone__error",
countrySelect: {
button: "phone__country-button",
dropdown: "phone__country-dropdown",
option: "phone__country-option",
},
}}
/>PhoneInput props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | string | — | Controlled input value. |
| defaultValue | string | "" | Initial value for uncontrolled usage. |
| onValueChange | (data) => void | — | Called with formatted value and parsed metadata. |
| country | PhoneCountryCode | — | Controlled selected country. |
| defaultCountry | PhoneCountryCode | "US" | Initial country for uncontrolled usage. |
| onCountryChange | (country, data) => void | — | Called when selected country changes. |
| countries | PhoneCountryCode[] | all countries | Optional list of allowed countries. |
| locale | string | "en" | Country name locale. |
| label | ReactNode | — | Input label. |
| helperText | ReactNode | — | Helper text below the input. |
| error | ReactNode | — | Error text below the input. |
| disabled | boolean | false | Disables the input and country selector. |
| required | boolean | false | Marks the field as required. |
| placeholder | string | generated | Custom placeholder. |
| autoPlaceholder | boolean | true | Generates country-specific placeholder. |
| international | boolean | false | Formats value with international calling code. |
| withCountrySelect | boolean | true | Shows or hides the country selector. |
| clearOnCountryChange | boolean | true | Clears value when country changes. |
| limitMaxLength | boolean | true | Limits input length by selected country. |
| maxNationalDigits | number | country example length | Overrides national digit limit. |
| virtualizeCountryList | boolean | false | Enables dropdown virtualization. |
| renderFlag | (country) => ReactNode | — | Custom country flag renderer. |
| className | string | — | Root class name. |
| classNames | PhoneInputClassNames | — | Internal class name overrides. |
Public exports
import {
PhoneInput,
CountrySelect,
usePhoneInput,
formatPhoneInput,
parsePhoneInput,
getPhoneInputValueData,
normalizePhoneDigits,
getPhoneCountries,
getPhoneCountry,
getPhoneCountryCallingCode,
getPhoneCountryFlag,
getPhoneCountryName,
getPhoneExample,
getPhoneNationalDigitsLimit,
} from "@eroyeroy/react-phone-mask";Browser support
The package targets modern React applications and works with Vite, Next.js, Remix and other bundlers that support ESM/CJS packages.
Peer dependencies
{
"react": ">=18",
"react-dom": ">=18"
}License
MIT
