@desource/phone-mask-svelte
v1.3.1
Published
π Svelte 5 component, composable, action, and attachment for international phone number masking. Powered by @desource/phone-mask with Google libphonenumber sync.
Maintainers
Readme
@desource/phone-mask-svelte
Svelte 5 phone input component with Google's libphonenumber data
Beautiful, accessible, extreme small & tree-shakeable Svelte 5 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
- π§© Four modes β Component, composable, attachment, or action
- β‘ Optimized β Tree-shaking and code splitting
π¦ Installation
npm install @desource/phone-mask-svelte
# or
yarn add @desource/phone-mask-svelte
# or
pnpm add @desource/phone-mask-svelteπ Quick Start
Importing
Component mode:
import { PhoneInput } from '@desource/phone-mask-svelte';
import '@desource/phone-mask-svelte/assets/lib.css'; // Import stylesComposable mode:
import { usePhoneMask } from '@desource/phone-mask-svelte';Core helpers (direct re-exports from @desource/phone-mask):
import { getFlagEmoji, formatDigitsWithMap } from '@desource/phone-mask-svelte/core';Attachment mode (for existing <input> elements, requires Svelte 5.29+):
import { phoneMaskAttachment as phoneMask } from '@desource/phone-mask-svelte';Action mode (for existing <input> elements, works with all Svelte 5 versions):
import { phoneMaskAction as phoneMask } from '@desource/phone-mask-svelte';Component Mode
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
import '@desource/phone-mask-svelte/assets/lib.css';
let phone = $state('');
let isValid = $state(false);
</script>
<PhoneInput bind:value={phone} country="US" onvalidationchange={(v) => (isValid = v)} />
{#if isValid}
<p>β Valid phone number</p>
{/if}Composable Mode
For custom input implementations:
<script lang="ts">
import { usePhoneMask } from '@desource/phone-mask-svelte';
let value = $state('');
const phoneMask = usePhoneMask({
value: () => value,
onChange: (digits) => (value = digits),
country: () => 'US',
detect: () => false
});
</script>
<div>
<input bind:this={phoneMask.inputRef} type="tel" placeholder="Phone number" />
<p>Formatted: {phoneMask.fullFormatted}</p>
<p>Valid: {phoneMask.isComplete ? 'Yes' : 'No'}</p>
<p>Country: {phoneMask.country.name}</p>
<button onclick={() => phoneMask.setCountry('GB')}>Use UK</button>
</div>Attachment Mode
For existing <input> elements without a wrapper component. Requires Svelte 5.29+.
<script lang="ts">
import { phoneMaskAttachment as phoneMask } from '@desource/phone-mask-svelte';
import type { PMaskPhoneNumber, PMaskFull } from '@desource/phone-mask-svelte';
let country = $state('US');
function handleChange(phone: PMaskPhoneNumber) {
console.log('Full:', phone.full, 'Digits:', phone.digits);
}
function handleCountryChange(c: PMaskFull) {
console.log('Country:', c.name);
}
</script>
<div class="phone-wrapper">
<select bind:value={country}>
<option value="US">πΊπΈ +1</option>
<option value="GB">π¬π§ +44</option>
<option value="DE">π©πͺ +49</option>
</select>
<input
{@attach phoneMask({ country, onChange: handleChange, onCountryChange: handleCountryChange })}
placeholder="Phone number"
/>
</div>Shorthand (country code string):
<input {@attach phoneMask('US')} />With auto-detection:
<input {@attach phoneMask({ detect: true, onChange: handleChange })} />Action Mode
For existing <input> elements without a wrapper component. Works with all Svelte 5 versions (including pre-5.29). Reactive parameter changes are applied through Svelte's use: action update() lifecycle hook.
<script lang="ts">
import { phoneMaskAction as phoneMask } from '@desource/phone-mask-svelte';
import type { PMaskPhoneNumber, PMaskFull } from '@desource/phone-mask-svelte';
let country = $state('US');
function handleChange(phone: PMaskPhoneNumber) {
console.log('Full:', phone.full, 'Digits:', phone.digits);
}
function handleCountryChange(c: PMaskFull) {
console.log('Country:', c.name);
}
</script>
<div class="phone-wrapper">
<select bind:value={country}>
<option value="US">πΊπΈ +1</option>
<option value="GB">π¬π§ +44</option>
<option value="DE">π©πͺ +49</option>
</select>
<input
use:phoneMask={{ country, onChange: handleChange, onCountryChange: handleCountryChange }}
placeholder="Phone number"
/>
</div>Shorthand (country code string):
<input use:phoneMask={'US'} />With auto-detection:
<input use:phoneMask={{ detect: true, onChange: handleChange }} />Send Raw Digits to Backend
<script lang="ts">
import { PhoneInput, type PMaskPhoneNumber } from '@desource/phone-mask-svelte';
let digits = $state('');
async function handlePhoneChange(phone: PMaskPhoneNumber) {
await fetch('/api/profile/phone', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
phoneDigits: phone.digits, // unformatted value for backend
phoneFull: phone.full // optional full number with country code
})
});
}
</script>
<PhoneInput bind:value={digits} country="US" onchange={handlePhoneChange} />Dynamic Mask Updates on Country Change
<script lang="ts">
import { usePhoneMask, type PCountryKey } from '@desource/phone-mask-svelte';
let selectedCountry = $state<PCountryKey>('US');
let digits = $state('');
const phoneMask = usePhoneMask({
value: () => digits,
onChange: (value) => (digits = value),
country: () => selectedCountry
});
function onCountrySelect(nextCountry: PCountryKey) {
selectedCountry = nextCountry;
phoneMask.setCountry(nextCountry); // updates mask immediately
}
</script>
<select
value={selectedCountry}
onchange={(e) => onCountrySelect((e.currentTarget as HTMLSelectElement).value as PCountryKey)}
>
<option value="US">US</option>
<option value="GB">GB</option>
<option value="DE">DE</option>
</select>
<input bind:this={phoneMask.inputRef} type="tel" />
<p>{phoneMask.fullFormatted}</p>
<p>{phoneMask.isComplete ? 'complete' : 'incomplete'}</p>Multi-tenant: tenantId Default Country + Tenant-specific Validation Rules
<script lang="ts">
import { usePhoneMask, type PCountryKey } from '@desource/phone-mask-svelte';
type TenantPolicy = {
defaultCountry: PCountryKey;
prefixRule?: RegExp;
};
let { tenantId }: { tenantId: string } = $props();
const TENANT_POLICIES: Record<string, TenantPolicy> = {
acme: { defaultCountry: 'US', prefixRule: /^(202|303)\d{7}$/ },
globex: { defaultCountry: 'GB', prefixRule: /^7\d{9}$/ }
};
const policy = $derived(TENANT_POLICIES[tenantId] ?? { defaultCountry: 'US' as const });
let digits = $state('');
const phoneMask = usePhoneMask({
value: () => digits,
onChange: (value) => (digits = value),
country: () => policy.defaultCountry
});
const isTenantValid = $derived(
phoneMask.isComplete && (policy.prefixRule ? policy.prefixRule.test(phoneMask.digits) : true)
);
</script>
<input bind:this={phoneMask.inputRef} type="tel" />
<p>Default country: {policy.defaultCountry}</p>
<p>Tenant validation: {isTenantValid ? 'pass' : 'fail'}</p>π Component API
Props
Note: The component supports both controlled and bindable modes. Use
bind:valuefor two-way binding orvalue+onchangefor controlled mode.
interface PhoneInputProps {
// Bindable value (digits only, without country code)
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
// Extra CSS class merged onto root element
class?: string;
// 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)
onchange?: (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) => void;
// Callback when the input is blurred
onblur?: (event: FocusEvent) => void;
// Callback when phone number is copied
oncopy?: (value: string) => void;
// Callback when input is cleared
onclear?: () => void;
}Exposed Methods
Access component methods via bind:this:
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
import type { PhoneInputExposed } from '@desource/phone-mask-svelte';
let phoneInput = $state<PhoneInputExposed | null>(null);
</script>
<PhoneInput bind:this={phoneInput} />
<button onclick={() => phoneInput?.focus()}>Focus</button>interface PhoneInputExposed {
focus: () => void; // Focus the input
blur: () => void; // Blur the input
clear: () => void; // Clear the input value
selectCountry: (code: string) => void; // Programmatically select a country by ISO code
getFullNumber: () => string; // Returns full phone number with country code (e.g. +1234567890)
getFullFormattedNumber: () => string; // Returns formatted number with country code (e.g. +1 234-567-890)
getDigits: () => string; // Returns only digits without country code (e.g. 234567890)
isValid: () => boolean; // Checks if the current phone number is valid
isComplete: () => boolean; // Alias for isValid()
}Snippets
<PhoneInput bind:value={phone}>
{#snippet flag(country)}
<img src="/flags/{country.code.toLowerCase()}.svg" alt={country.name} />
{/snippet}
{#snippet copysvg(copied)}
{copied ? 'β' : 'π'}
{/snippet}
{#snippet clearsvg()}
β
{/snippet}
{#snippet actionsbefore()}
<button onclick={handleCustomAction}>Custom</button>
{/snippet}
</PhoneInput>| Snippet | Props | Description |
| --------------- | ------------------------ | ------------------------------------------------- |
| flag | MaskFull | Custom flag icon in the country list and selector |
| copysvg | boolean (copied state) | Custom copy button icon |
| clearsvg | β | Custom clear button icon |
| actionsbefore | β | Content rendered before default action buttons |
π§© Composable API
Options
Note: The composable uses getter functions for reactive options. Do NOT pass values directly.
interface UsePhoneMaskOptions {
// Getter returning current digit value (controlled) - REQUIRED
value: () => string;
// Callback when the digits value changes - REQUIRED
onChange: (digits: string) => void;
// Getter for ISO country code (e.g., 'US', 'DE', 'GB')
country?: () => string | undefined;
// Getter for locale string (default: navigator.language)
locale?: () => string | undefined;
// Getter for auto-detect flag (default: false)
detect?: () => boolean | undefined;
// Callback when the phone changes (full, fullFormatted, digits)
onPhoneChange?: (phone: PhoneNumber) => void;
// Country change callback
onCountryChange?: (country: MaskFull) => void;
}Return Value
Important: Do NOT destructure the returned object β all properties are reactive getters and destructuring breaks reactivity.
interface UsePhoneMaskReturn {
// Ref to attach to your input element
inputRef: 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;
// Current locale used for country names
locale: string;
// Change country programmatically
setCountry: (countryCode?: string | null) => boolean;
// Clear the input
clear: () => void;
}<script lang="ts">
// β
CORRECT β access as properties
const phoneMask = usePhoneMask(options);
phoneMask.digits;
// β WRONG β loses reactivity
const { digits } = usePhoneMask(options);
</script>β‘ Attachment API
The phoneMaskAttachment Svelte attachment (Svelte 5.29+) applies phone masking directly to any <input> element via {@attach phoneMaskAttachment(...)}. Unlike use: actions, the attachment factory re-runs reactively when reactive state in the call site changes β no manual update() needed.
Basic Usage
<script lang="ts">
import { phoneMaskAttachment } from '@desource/phone-mask-svelte';
</script>
<input {@attach phoneMaskAttachment('US')} />Options
import type { PMaskFull, PMaskPhoneNumber } from '@desource/phone-mask-svelte';
interface PhoneMaskBindingOptions {
// 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;
// Value change callback
onChange?: (phone: PMaskPhoneNumber) => void;
// Country change callback
onCountryChange?: (country: PMaskFull) => void;
}The parameter can be a country code string (shorthand) or an options object:
<!-- Shorthand -->
<input {@attach phoneMaskAttachment('DE')} />
<!-- Full options -->
<input {@attach phoneMaskAttachment({ country: 'DE', onChange: handleChange })} />
<!-- Auto-detect -->
<input {@attach phoneMaskAttachment({ detect: true, onCountryChange: handleCountryChange })} />Reactive Country
Pass reactive state directly β the factory re-runs automatically when selectedCountry changes:
<script lang="ts">
import { phoneMaskAttachment as phoneMask } from '@desource/phone-mask-svelte';
let selectedCountry = $state('US');
let phoneData = $state<{ full: string; digits: string } | null>(null);
</script>
<select bind:value={selectedCountry}>
<option value="US">πΊπΈ United States</option>
<option value="GB">π¬π§ United Kingdom</option>
<option value="DE">π©πͺ Germany</option>
</select>
<input {@attach phoneMask({ country: selectedCountry, onChange: (p) => (phoneData = p) })} placeholder="Phone number" />π¬ Action API
The phoneMaskAction Svelte action applies phone masking directly to any <input> element via use:phoneMaskAction. It works with all Svelte 5 versions β no Svelte 5.29+ required.
When the bound parameter object changes (e.g. a new country value), Svelte automatically calls the action's update() hook, which re-applies the new options and switches the country if needed.
Basic Usage
<script lang="ts">
import { phoneMaskAction } from '@desource/phone-mask-svelte';
</script>
<input use:phoneMaskAction={'US'} />Options
import type { PMaskFull, PMaskPhoneNumber } from '@desource/phone-mask-svelte';
interface PhoneMaskBindingOptions {
// Predefined country ISO code (e.g., 'US', 'DE', 'GB')
country?: string;
// Locale for country names (default: navigator.language)
locale?: string;
// Auto-detect country from GeoIP on mount; falls back to locale detection
detect?: boolean;
// Value change callback β fires on every phone number change
onChange?: (phone: PMaskPhoneNumber) => void;
// Country change callback β fires on initial mount and on country switch
onCountryChange?: (country: PMaskFull) => void;
}The parameter can be a country code string (shorthand) or an options object:
<!-- Shorthand -->
<input use:phoneMaskAction={'DE'} />
<!-- Full options -->
<input use:phoneMaskAction={{ country: 'DE', onChange: handleChange }} />
<!-- Auto-detect -->
<input use:phoneMaskAction={{ detect: true, onCountryChange: handleCountryChange }} />Reactive Country
Pass reactive $state inside the options object β Svelte calls update() automatically when selectedCountry changes:
<script lang="ts">
import { phoneMaskAction as phoneMask } from '@desource/phone-mask-svelte';
let selectedCountry = $state('US');
let phoneData = $state<{ full: string; digits: string } | null>(null);
</script>
<select bind:value={selectedCountry}>
<option value="US">πΊπΈ United States</option>
<option value="GB">π¬π§ United Kingdom</option>
<option value="DE">π©πͺ Germany</option>
</select>
<input use:phoneMask={{ country: selectedCountry, onChange: (p) => (phoneData = p) }} placeholder="Phone number" />Action vs Attachment
| | use:phoneMaskAction | {@attach phoneMaskAttachment(...)} |
| ------------------------ | ----------------------------- | ------------------------------------ |
| Svelte version required | All Svelte 5 | Svelte 5.29+ |
| Reactivity mechanism | update() hook (auto-called) | Factory re-runs reactively |
| Manual update() needed | No (Svelte handles it) | No |
π¨ 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 bind:value={phone} theme="dark" />Or with CSS:
.phone-input[data-theme='dark'] {
--pi-bg: #1f2937;
--pi-fg: #f9fafb;
--pi-border: #374151;
}π Examples
With Validation
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
let phone = $state('');
let isValid = $state(false);
const errorMessage = $derived(!phone ? '' : isValid ? '' : 'Please enter a valid phone number');
</script>
<div>
<PhoneInput bind:value={phone} country="US" onvalidationchange={(v) => (isValid = v)} />
{#if errorMessage}
<span class="error">{errorMessage}</span>
{/if}
</div>Auto-detect Country
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
import type { PMaskFull } from '@desource/phone-mask-svelte';
let phone = $state('');
let detectedCountry = $state('');
function handleCountryChange(country: PMaskFull) {
detectedCountry = country.name;
}
</script>
<PhoneInput bind:value={phone} detect oncountrychange={handleCountryChange} />
{#if detectedCountry}
<p>Detected: {detectedCountry}</p>
{/if}Programmatic Control
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
import type { PhoneInputExposed } from '@desource/phone-mask-svelte';
let phone = $state('');
let phoneInput = $state<PhoneInputExposed | null>(null);
</script>
<PhoneInput bind:this={phoneInput} bind:value={phone} />
<div>
<button onclick={() => phoneInput?.focus()}>Focus</button>
<button onclick={() => phoneInput?.clear()}>Clear</button>
<button onclick={() => phoneInput?.selectCountry('GB')}>Switch to UK</button>
<p>Full: {phoneInput?.getFullFormattedNumber()}</p>
<p>Valid: {phoneInput?.isValid()}</p>
</div>Multiple Inputs
<script lang="ts">
import { PhoneInput } from '@desource/phone-mask-svelte';
let form = $state({ mobile: '', home: '', work: '' });
</script>
<div class="form">
<label>
Mobile
<PhoneInput bind:value={form.mobile} country="US" />
</label>
<label>
Home
<PhoneInput bind:value={form.home} country="US" />
</label>
<label>
Work
<PhoneInput bind:value={form.work} country="US" />
</label>
</div>Custom Composable Implementation
<script lang="ts">
import { usePhoneMask } from '@desource/phone-mask-svelte';
import type { PMaskPhoneNumber } from '@desource/phone-mask-svelte';
let inputValue = $state('');
let selectedCountry = $state('US');
const phoneMask = usePhoneMask({
value: () => inputValue,
country: () => selectedCountry,
detect: () => false,
onChange: (digits) => {
inputValue = digits;
},
onPhoneChange: (data: PMaskPhoneNumber) => {
console.log('Phone:', data.fullFormatted);
}
});
</script>
<div class="custom-phone">
<select bind:value={selectedCountry}>
<option value="US">πΊπΈ +1</option>
<option value="GB">π¬π§ +44</option>
<option value="DE">π©πͺ +49</option>
</select>
<input bind:this={phoneMask.inputRef} type="tel" placeholder="Phone number" />
</div>
<p>Formatted: {phoneMask.fullFormatted}</p>
<p>Valid: {phoneMask.isComplete ? 'Yes' : 'No'}</p>
<p>Country: {phoneMask.country.name}</p>π― Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- iOS Safari 14+
- Chrome Mobile
π¦ What's Included
@desource/phone-mask-svelte/
βββ dist/
β βββ index.mjs # Main ESM/Svelte entry
β βββ index.cjs # Main CommonJS entry
β βββ core.mjs # Core helpers subpath (@desource/phone-mask-svelte/core)
β βββ core.cjs # Core helpers CJS subpath
β βββ phone-mask-svelte.css # Component styles
β βββ types/ # TypeScript declaration files
βββ 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-react β React bindings
π License
MIT Β© 2026 DeSource Labs
