# @desource/phone-mask-react

> React phone input component and hook with smart masking and Google libphonenumber data

[![npm version](https://img.shields.io/npm/v/@desource/phone-mask-react?color=blue&logo=react)](https://www.npmjs.com/package/@desource/phone-mask-react)
[![codewiki](https://img.shields.io/badge/code%20wiki-000000?logo=googlegemini&logoColor=white)](https://codewiki.google/github.com/desource-labs/phone-mask)
[![bundle size](https://img.shields.io/bundlephobia/minzip/@desource/phone-mask-react?label=gzip%20size&color=purple)](https://bundlephobia.com/package/@desource/phone-mask-react)
[![context7 docs](https://img.shields.io/badge/context7%20docs-000000)](https://context7.com/desource-labs/phone-mask)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/DeSource-Labs/phone-mask/blob/main/LICENSE)

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

```bash
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:

```tsx
import { PhoneInput } from '@desource/phone-mask-react';
import '@desource/phone-mask-react/assets/lib.css'; // Import styles
```

Hook mode:

```tsx
import { usePhoneMask } from '@desource/phone-mask-react';
```

Core kit utilities and mask data re-exported from the core package subpaths:

```ts
import { getFlagEmoji, formatDigitsWithMap } from '@desource/phone-mask-react/core';
```

### Component Mode

```tsx
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:

```tsx
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

```tsx
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

```tsx
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

```tsx
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 `value` and `onChange` props are required.

```ts
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

```tsx
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 `value` and `onChange` options are required.

```ts
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

```ts
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:

```css
.desource-phone-input,
.desource-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

```tsx
<PhoneInput value={phone} theme="dark" />
```

Or with CSS:

```css
.desource-phone-input.theme-dark {
  --pi-bg: #1f2937;
  --pi-fg: #f9fafb;
  --pi-border: #374151;
}
```

## 📚 Examples

### With Validation

```tsx
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

```tsx
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

```tsx
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

```tsx
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 kit utilities and mask data subpath
│   ├── core.cjs                # Core kit utilities and mask data CJS subpath
│   ├── phone-mask-react.css    # Component styles
│   └── types/                  # TypeScript declarations
├── README.md                   # This file
└── package.json                # Package manifest
```

## 🔗 Related

- [@desource/phone-mask](../phone-mask) — Core library
- [@desource/phone-mask-nuxt](../phone-mask-nuxt) — Nuxt module
- [@desource/phone-mask-vue](../phone-mask-vue) — Vue 3 bindings
- [@desource/phone-mask-svelte](../phone-mask-svelte) — Svelte bindings

## 📄 License

[MIT](../../LICENSE) © 2026 DeSource Labs

## 🤝 Contributing

See [Contributing Guide](../../CONTRIBUTING.md)

---

<div align="center">
  <sub>Made with ❤️ by <a href="https://github.com/DeSource-Labs">DeSource Labs</a></sub>
</div>
