@desource/phone-mask-react
v1.3.1
Published
π± React component and hook for international phone number input with smart masking. Powered by @desource/phone-mask and Google libphonenumber.
Downloads
9,068
Maintainers
Readme
@desource/phone-mask-react
React phone input component and hook with smart masking and Google libphonenumber data
Beautiful, accessible, extreme small & tree-shakeable React phone input with auto-formatting, country selector, and validation.
β¨ Features
- π¨ Beautiful UI β Modern design with light/dark themes
- π Smart Country Search β Fuzzy matching with keyboard navigation
- π Auto-formatting β As-you-type formatting with smart cursor
- β Validation β Built-in validation with visual feedback
- π Copy Button β One-click copy to clipboard
- π Auto-detection β GeoIP and locale-based detection
- βΏ Accessible β ARIA labels, keyboard navigation
- π± Mobile-friendly β Optimized for touch devices
- π― TypeScript β Full type safety
- πͺ Hook API β For custom input implementations
- β‘ Optimized β Tree-shaking and code splitting
π¦ Installation
npm install @desource/phone-mask-react
# or
yarn add @desource/phone-mask-react
# or
pnpm add @desource/phone-mask-reactπ Quick Start
Importing
Component mode:
import { PhoneInput } from '@desource/phone-mask-react';
import '@desource/phone-mask-react/assets/lib.css'; // Import stylesHook mode:
import { usePhoneMask } from '@desource/phone-mask-react';Core helpers (direct re-exports from @desource/phone-mask):
import { getFlagEmoji, formatDigitsWithMap } from '@desource/phone-mask-react/core';Component Mode
import { useState } from 'react';
import { PhoneInput } from '@desource/phone-mask-react';
import '@desource/phone-mask-react/assets/lib.css'; // Import styles
function App() {
const [phone, setPhone] = useState('');
const [isValid, setIsValid] = useState(false);
return (
<>
<PhoneInput value={phone} onChange={setPhone} onValidationChange={setIsValid} country="US" />
{isValid && <p>β Valid phone number</p>}
</>
);
}Hook Mode
For custom input implementations:
import { useState } from 'react';
import { usePhoneMask } from '@desource/phone-mask-react';
function CustomPhoneInput() {
const [value, setValue] = useState('');
const { ref, digits, full, fullFormatted, isComplete, country, setCountry } = usePhoneMask({
value,
onChange: setValue,
country: 'US',
detect: true
});
return (
<div>
<input ref={ref} type="tel" placeholder="Phone number" />
<p>Formatted: {fullFormatted}</p>
<p>Valid: {isComplete ? 'Yes' : 'No'}</p>
<p>Country: {country.name}</p>
<button onClick={() => setCountry('GB')}>Use UK</button>
</div>
);
}Send Raw Digits to Backend
import { useState } from 'react';
import { PhoneInput, type PMaskPhoneNumber } from '@desource/phone-mask-react';
function CheckoutForm() {
const [digits, setDigits] = useState('');
const handlePhoneChange = (phone: PMaskPhoneNumber) => {
const payload = {
phoneDigits: phone.digits, // unformatted, backend-friendly
phoneFull: phone.full // optional full number with country code
};
setDigits(phone.digits);
void fetch('/api/profile/phone', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
};
return <PhoneInput value={digits} onChange={setDigits} country="US" onPhoneChange={handlePhoneChange} />;
}Dynamic Mask Updates on Country Change
import { useMemo, useState } from 'react';
import { usePhoneMask, type PCountryKey } from '@desource/phone-mask-react';
function CountryAwareInput() {
const [digits, setDigits] = useState('');
const [country, setCountry] = useState<PCountryKey>('US');
const {
ref,
fullFormatted,
isComplete,
setCountry: setMaskCountry
} = usePhoneMask({
value: digits,
onChange: setDigits,
country
});
const onCountrySelect = (next: PCountryKey) => {
setCountry(next);
setMaskCountry(next); // update formatter immediately
};
const label = useMemo(() => (isComplete ? 'complete' : 'incomplete'), [isComplete]);
return (
<div>
<select value={country} onChange={(e) => onCountrySelect(e.target.value as PCountryKey)}>
<option value="US">US</option>
<option value="GB">GB</option>
<option value="DE">DE</option>
</select>
<input ref={ref} type="tel" />
<p>{fullFormatted}</p>
<p>{label}</p>
</div>
);
}Multi-tenant: tenantId Default Country + Tenant-specific Validation Rules
import { useMemo, useState } from 'react';
import { usePhoneMask, type PCountryKey } from '@desource/phone-mask-react';
type TenantPolicy = {
defaultCountry: PCountryKey;
prefixRule?: RegExp;
};
const TENANT_POLICIES: Record<string, TenantPolicy> = {
acme: { defaultCountry: 'US', prefixRule: /^(202|303)\d{7}$/ },
globex: { defaultCountry: 'GB', prefixRule: /^7\d{9}$/ }
};
export function TenantPhoneInput({ tenantId }: { tenantId: string }) {
const policy = TENANT_POLICIES[tenantId] ?? { defaultCountry: 'US' as const };
const [digits, setDigits] = useState('');
const phoneMask = usePhoneMask({
value: digits,
onChange: setDigits,
country: policy.defaultCountry
});
const isTenantValid = useMemo(() => {
if (!phoneMask.isComplete) return false;
return policy.prefixRule ? policy.prefixRule.test(phoneMask.digits) : true;
}, [phoneMask.isComplete, phoneMask.digits, policy.prefixRule]);
return (
<div>
<input ref={phoneMask.ref} type="tel" />
<p>Default country: {policy.defaultCountry}</p>
<p>Tenant validation: {isTenantValid ? 'pass' : 'fail'}</p>
</div>
);
}π Component API
Props
Note: The component requires controlled behavior. Both
valueandonChangeprops are required.
interface PhoneInputProps {
// Controlled value (digits only, without country code) - REQUIRED
value: string;
// Optional id/name applied to the underlying <input> for forms/autofill
id?: string;
name?: string;
// Preselected country (ISO 3166-1 alpha-2)
country?: CountryKey;
// Auto-detect country from IP/locale
detect?: boolean; // Default: true
// Locale for country names
locale?: string; // Default: browser language
// Size variant
size?: 'compact' | 'normal' | 'large'; // Default: 'normal'
// Visual theme ("auto" | "light" | "dark")
theme?: 'auto' | 'light' | 'dark'; // Default: 'auto'
// Disabled state
disabled?: boolean; // Default: false
// Readonly state
readonly?: boolean; // Default: false
// Show copy button
showCopy?: boolean; // Default: true
// Show clear button
showClear?: boolean; // Default: false
// Show validation state (borders & outline)
withValidity?: boolean; // Default: true
// Custom search placeholder
searchPlaceholder?: string; // Default: 'Search country or code...'
// Custom no results text
noResultsText?: string; // Default: 'No countries found'
// Custom clear button label
clearButtonLabel?: string; // Default: 'Clear phone number'
// Dropdown menu custom CSS class
dropdownClass?: string;
// Disable default styles
disableDefaultStyles?: boolean; // Default: false
// Callback when the digits value changes - REQUIRED
// Returns only the digits without country code (e.g. '234567890')
onChange: (digits: string) => void;
// Callback when the phone number changes.
// Provides an object with:
// - full: Full phone number with country code (e.g. +1234567890)
// - fullFormatted: Full phone number formatted according to country rules (e.g. +1 234-567-890)
// - digits: Only the digits of the phone number without country code (e.g. 234567890)
onPhoneChange?: (value: PhoneNumber) => void;
// Callback when the selected country changes
onCountryChange?: (country: MaskFull) => void;
// Callback when the validation state changes
onValidationChange?: (isValid: boolean) => void;
// Callback when the input is focused
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
// Callback when the input is blurred
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
// Callback when phone number is copied
onCopy?: (value: string) => void;
// Callback when input is cleared
onClear?: () => void;
// Render custom action buttons before default ones
renderActionsBefore?: () => ReactNode;
// Render custom flag icons in the country list and country selector
renderFlag?: (country: MaskFull) => ReactNode;
// Render custom copy button SVG
renderCopySvg?: (copied: boolean) => ReactNode;
// Render custom clear button SVG
renderClearSvg?: () => ReactNode;
}Ref Methods
const phoneInputRef = useRef<PhoneInputRef>(null);
phoneInputRef.current?.focus(); // Focuses the input
phoneInputRef.current?.blur(); // Blurs the input
phoneInputRef.current?.clear(); // Clears the input value
phoneInputRef.current?.selectCountry('GB'); // Programmatically selects a country by ISO code (e.g., 'US', 'DE', 'GB')
phoneInputRef.current?.getFullNumber(); // Returns the full phone number with country code (e.g., +1234567890)
phoneInputRef.current?.getFullFormattedNumber(); // Returns the full phone number formatted according to country rules (e.g., +1 234-567-890)
phoneInputRef.current?.getDigits(); // Returns only the digits of the phone number without country code (e.g., 234567890)
phoneInputRef.current?.isValid(); // Checks if the current phone number is valid
phoneInputRef.current?.isComplete(); // Checks if the current phone number is completeπͺ Hook API
Options
Note: The hook requires controlled behavior. Both
valueandonChangeoptions are required.
interface UsePhoneMaskOptions {
// Controlled value (digits only, without country code) - REQUIRED
value: string;
// Callback when the digits value changes - REQUIRED
onChange: (digits: string) => void;
// Predefined country ISO code (e.g., 'US', 'DE', 'GB')
country?: string;
// Locale for country names (default: navigator.language)
locale?: string;
// Auto-detect country from IP/locale (default: false)
detect?: boolean;
// Callback when the phone changes (full, fullFormatted, digits)
onPhoneChange?: (phone: PhoneNumber) => void;
// Country change callback
onCountryChange?: (country: MaskFull) => void;
}Return Value
interface UsePhoneMaskReturn {
// Ref to attach to your input element
ref: RefObject<HTMLInputElement | null>;
// Raw digits without formatting (e.g., "1234567890")
digits: string;
// Phone formatter instance
formatter: FormatterHelpers;
// Full phone number with country code (e.g., "+11234567890")
full: string;
// Full phone number formatted (e.g., "+1 123-456-7890")
fullFormatted: string;
// Whether the phone number is complete
isComplete: boolean;
// Whether the input is empty
isEmpty: boolean;
// Whether to show validation warning
shouldShowWarn: boolean;
// Current country data
country: MaskFull;
// Change country programmatically
setCountry: (countryCode: string) => void;
// Clear the input
clear: () => void;
}π¨ Component Styling
CSS Custom Properties
Customize colors via CSS variables:
.phone-input,
.phone-dropdown {
/* Colors */
--pi-bg: #ffffff;
--pi-fg: #111827;
--pi-muted: #6b7280;
--pi-border: #e5e7eb;
--pi-border-hover: #d1d5db;
--pi-border-focus: #3b82f6;
--pi-focus-ring: 3px solid rgb(59 130 246 / 0.15);
--pi-disabled-bg: #f9fafb;
--pi-disabled-fg: #9ca3af;
/* Sizes */
--pi-font-size: 16px;
--pi-height: 44px;
/* Spacing */
--pi-padding: 12px;
/* Border radius */
--pi-radius: 8px;
/* Shadows */
--pi-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--pi-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05);
/* Validation */
--pi-warning: #f59e0b;
--pi-warning-light: #fbbf24;
--pi-success: #10b981;
--pi-focus-ring-warning: 3px solid rgb(245 158 11 / 0.15);
--pi-focus-ring-success: 3px solid rgb(16 185 129 / 0.15);
}Dark Theme
<PhoneInput value={phone} theme="dark" />Or with CSS:
.phone-input[data-theme='dark'] {
--pi-bg: #1f2937;
--pi-fg: #f9fafb;
--pi-border: #374151;
}π Examples
With Validation
import { useState } from 'react';
import { PhoneInput } from '@desource/phone-mask-react';
function Example() {
const [phone, setPhone] = useState('');
const [isValid, setIsValid] = useState(false);
return (
<div>
<PhoneInput value={phone} onChange={setPhone} onValidationChange={setIsValid} />
{isValid && <span>β Valid phone number</span>}
</div>
);
}Auto-detect Country
import { useState } from 'react';
import { PhoneInput } from '@desource/phone-mask-react';
function Example() {
const [phone, setPhone] = useState('');
const [detectedCountry, setDetectedCountry] = useState('');
return (
<>
<PhoneInput
value={phone}
detect
onChange={setPhone}
onCountryChange={(country) => setDetectedCountry(country.name)}
/>
{detectedCountry && <p>Detected: {detectedCountry}</p>}
</>
);
}With Form Libraries
React Hook Form
import { useForm, Controller } from 'react-hook-form';
import { PhoneInput } from '@desource/phone-mask-react';
function Example() {
const { control } = useForm({
defaultValues: { phone: '' }
});
return (
<Controller
name="phone"
control={control}
render={({ field }) => (
<PhoneInput value={field.value} onChange={(digits) => field.onChange(digits)} onBlur={field.onBlur} />
)}
/>
);
}Multiple Inputs
import { useState } from 'react';
import { PhoneInput } from '@desource/phone-mask-react';
function Example() {
const [form, setForm] = useState({ mobile: '', home: '', work: '' });
return (
<div className="form">
<label>
Mobile
<PhoneInput value={form.mobile} onChange={(digits) => setForm({ ...form, mobile: digits })} />
</label>
<label>
Home
<PhoneInput value={form.home} onChange={(digits) => setForm({ ...form, home: digits })} />
</label>
<label>
Work
<PhoneInput value={form.work} onChange={(digits) => setForm({ ...form, work: digits })} />
</label>
</div>
);
}π― Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- iOS Safari 14+
- Chrome Mobile
π¦ What's Included
@desource/phone-mask-react/
βββ dist/
β βββ index.mjs # Main ESM entry
β βββ index.cjs # Main CommonJS entry
β βββ core.mjs # Core helpers subpath (@desource/phone-mask-react/core)
β βββ core.cjs # Core helpers CJS subpath
β βββ phone-mask-react.css # Component styles
β βββ types/ # TypeScript declarations
βββ README.md # This file
βββ package.json # Package manifestπ Related
- @desource/phone-mask β Core library
- @desource/phone-mask-nuxt β Nuxt module
- @desource/phone-mask-vue β Vue 3 bindings
- @desource/phone-mask-svelte β Svelte bindings
π License
MIT Β© 2026 DeSource Labs
