242 countries, SVG flags drawn by a 2 KB engine, as-you-type formatting, E.164 out. Zero dependencies — every card below is live.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/phone/phone.js"></script>
new PhoneInput('#phone', { country: 'ae' })
Type a number — the parsed value updates on every keystroke.
new PhoneInput('#basic', {
country: 'us',
onChange: v => show(v) // {e164, national, country, valid}
})
Pin the markets you serve to the top of the dropdown.
new PhoneInput('#preferred', {
country: 'ae',
preferredCountries: ['ae','sa','kw','qa','bh','om']
})
Paste (or click to paste) an international number — the flag follows the dial code.
// typing '+9715…' switches the flag to 🇦🇪 // longest-prefix match, +1/+7/+44 area-aware
validate: 'blur' (default) waits until you leave the field;
'live' judges every keystroke.
new PhoneInput('#a', { validate: 'blur' }) // Maz-style
new PhoneInput('#b', { validate: 'live',
onValidityChange: ok => … })
Container mode builds the control and a hidden named input
carrying the E.164 value.
<div id="form-phone"></div>
new PhoneInput('#form-phone', { name: 'phone' })
// <input type="hidden" name="phone" value="+9715…">
Same value, two displays — output is always E.164 either way.
new PhoneInput('#a', { nationalMode: true })
// shows 050 123 4567
new PhoneInput('#b', { nationalMode: false })
// shows +971 50 123 4567
Complete reference for PhoneInput v1.0.0, generated from the
component source (phone.js) and README. Zero dependencies; UMD (browser global,
CommonJS, AMD); SSR-safe — constructing without a DOM returns an inert handle, and the pure
helpers work in Node.
var phone = new PhoneInput(target, options) // or PhoneInput.create(target, options)
target is a CSS selector string or a DOM element. Two modes, decided by the
target's tag:
<input>: the input is adopted
in place (wrapped in the .vph control, coerced to type="tel", given the
vph-input class). destroy() unwraps and restores it.options.name a hidden input carrying the
E.164 value is added for form submission.Error('PhoneInput: target element not found: …') when the selector matches nothing.+ value also switches the country from its dial code.+ + dial code auto-detects the country (longest-prefix match, area-code
aware for shared codes like +1/+7/+44) and switches the flag. Input is restricted to digits and
+ ( ) - . space (+ only leading).Useful instance properties: phone.el (original target),
phone.input (the tel input), phone.root (the .vph wrapper),
phone.button (country button), phone.country (current country record),
phone.isOpen, phone.opts.
All options with their defaults, exactly as in PhoneInput.defaults (mutable —
change it to set global defaults before constructing).
| Name | Type | Default | Description |
|---|---|---|---|
country | string | 'us' |
Initial ISO-3166 alpha-2 country. If unknown or excluded, falls back to the first allowed country. |
preferredCountries | string[] | [] |
iso2 codes pinned to the top of the dropdown, above a separator. |
onlyCountries | string[] | null | null |
Whitelist of iso2 codes; all others are removed from the dropdown and auto-detection. |
excludeCountries | string[] | null | null |
Blacklist of iso2 codes. |
nationalMode | boolean | true |
Display national formatting (output is always E.164); false displays +971 50 123 4567 style. |
placeholder | 'auto' | string | false | 'auto' |
'auto' = example number for the current country; a string is used verbatim; false removes the placeholder. |
showDialCode | boolean | true |
Show +971 next to the flag in the country button. |
searchable | boolean | true |
Search box in the dropdown. When false, typeahead over country names kicks in instead. |
validate | 'blur' | 'live' | 'blur' |
'blur' shows the red/green state only after the field is first blurred (Maz-style); 'live' judges every keystroke. Either way the state shows only when digits are present. |
validator | function | null | null |
fn({country, digits, e164}) → boolean — replaces the built-in national-length check (digits are national significant digits, trunk 0 stripped). |
name | string | null | null |
Adds a hidden input with this name carrying the E.164 value (skipped in anchor mode when the input already has this name). |
disabled | boolean | false |
Start disabled (.vph-disabled, input and button disabled). |
theme | 'auto' | 'light' | 'dark' | 'auto' |
'auto' follows <html data-theme> / data-bs-theme / .dark/.light class → OS scheme, re-resolved live. |
styles | boolean | true |
false = headless: no CSS injected; full behavior and markup contract kept. |
labels | object | {country: 'Choose country', search: 'Search countries', noResults: 'No matches', invalid: 'Invalid phone number'} |
UI strings (button aria-label prefix, search placeholder/label, empty-list text, validation announcement). Merged shallowly with the defaults. |
onChange | function | null | null |
fn({e164, national, country, valid}) on every edit — see Events. |
onCountryChange | function | null | null |
fn(iso2) when the country switches (dropdown, auto-detect, setCountry, setValue). |
onValidityChange | function | null | null |
fn(valid) whenever validity flips (also fired once with the initial validity on construction). |
Note: the pure helper functions (PhoneInput.parse etc.) do
not take an options object — where they accept a second parameter it is an iso2 country
code string. See Statics & helpers.
All setters return the instance for chaining.
| Method | Returns | Description |
|---|---|---|
getValue() | {e164, national, country, valid} |
Current value. e164 is '' when empty; national is the formatted national display; country the current iso2; output is E.164 regardless of nationalMode. |
setValue(str) | this |
Set the number. A leading + switches the country from the dial code (if allowed); otherwise digits are applied to the current country. |
setCountry(iso2) | this |
Switch the flag/dial code. Ignored for unknown or excluded countries. |
getCountry() | string | Current iso2, e.g. 'ae'. |
setDisabled(disabled) | this |
Enable/disable the control (closes the dropdown when disabling). |
open() | this |
Open the country dropdown (no-op when already open or disabled). The panel is portaled to <body>, or into an open <dialog>'s top layer. |
close(refocus) | this |
Close the dropdown. Unless refocus === false, focus returns to the country button when it was inside the panel. |
focus() | this |
Focus the tel input. |
destroy() | this |
Remove everything, unbind all listeners. In anchor mode the original input is unwrapped, its class list restored and aria-invalid removed. |
Pure helpers — no DOM needed, usable in Node. None takes an options object; the optional second parameter is always an ISO-3166 alpha-2 string.
| Helper | Returns | Description |
|---|---|---|
PhoneInput.parse(str, iso2?) |
{country, dialCode, national, e164, valid} |
Parse any input. A leading + detects the country from the dial code (iso2 ignored); otherwise iso2 is used, falling back to PhoneInput.defaults.country, then 'us'. dialCode comes back as '+971'-style; national is the formatted national number. When a + number matches no known dial code: {country: null, dialCode: null, national: '', e164: '+<digits>', valid: false}. |
PhoneInput.format(str, iso2?) |
string | Format as a national number: PhoneInput.format('4155552671', 'us') → '(415) 555-2671'. A leading + routes through parse (iso2 ignored). Digits beyond the country's pattern are appended raw. |
PhoneInput.isValid(str, iso2?) |
boolean | Pragmatic length validation. With iso2 given for a + number, the detected country only needs to share the dial code (a +1 number satisfies both 'us' and 'ca'). |
PhoneInput.exampleNumber(iso2) |
string | Example national number for placeholders — the country's pattern filled with 1–9 cycling: PhoneInput.exampleNumber('gb') → '1234 567891'. '' for unknown iso2. |
PhoneInput.flag(iso2) |
string (SVG) | The 20×15 flag as an <svg> markup string (simplified, flagpack-style, generated by the built-in renderer). Unknown codes get a neutral rounded badge showing the uppercase iso2 — never an error. |
Other statics
| Static | Type / Returns | Description |
|---|---|---|
PhoneInput.create(target, options) | instance | Constructor alias: new PhoneInput(target, options). |
PhoneInput.get(target) | instance | null | Registry lookup by element or selector — the live instance attached to that element, or null. |
PhoneInput.autoInit(root?) | instance[] | Initialize every [data-vph] element under root (default document), skipping already-initialized ones. Returns the created instances; one bad element logs an error without aborting the rest. |
PhoneInput.defaults | object | The live defaults object (see Options). Mutate before constructing, e.g. PhoneInput.defaults.styles = false. |
PhoneInput.countries | array | The full data table — 242 records of {iso2, name, dialCode, order, lengths, pattern, areaCodes}. |
PhoneInput.version | string | '1.0.0'. |
PhoneInput.salt | string | false | CSS isolation token (default 'vc1'). Set your own token or false to disable, before the first instance. |
PhoneInput.css | string (getter) | The full stylesheet, rendered live with the current salt — a starting point for headless styling. |
PhoneInput.displayName / .rootClass / .themeVars / .varScopes | string / string / object / array | Family convergence contract used by the VC core: 'PhoneInput', 'vph', the map of themeable vars (accent, radius, font), and the CSS scopes where the vars are defined. |
The component does not dispatch custom DOM events — all notifications go through the three option callbacks:
| Callback | Payload | Fires |
|---|---|---|
onChange |
{e164: string, national: string, country: string, valid: boolean} |
On every edit — typing, paste, country selection that reformats the value, setValue(), setCountry(). Same shape as getValue(). |
onCountryChange |
iso2: string |
Whenever the country switches: dropdown pick, +-prefix auto-detection, setCountry(), setValue('+…'). |
onValidityChange |
valid: boolean |
Whenever computed validity flips, independent of whether the red/green state is visible yet; also once at construction with the initial validity. |
The underlying <input type="tel"> is a real input, so native
input/blur/focus events from user interaction behave normally;
programmatic setValue() does not fire native events.
Any element with data-vph is auto-initialized on DOMContentLoaded (or
immediately if the script loads after). Call PhoneInput.autoInit(root) for content added
later. The attribute value itself is a country shorthand: data-vph="ae" ≡
data-vph data-country="ae".
<input data-vph data-country="gb" data-preferred="gb,ie,us">
| Attribute | Maps to option | Value format |
|---|---|---|
data-vph | marker (+ country if a value is given) | empty, or an iso2 |
data-country | country | iso2 (wins over the data-vph shorthand) |
data-preferred | preferredCountries | comma-separated iso2 list |
data-only | onlyCountries | comma-separated iso2 list |
data-exclude | excludeCountries | comma-separated iso2 list |
data-name | name | string |
data-placeholder | placeholder | string; the literal "false" means no placeholder |
data-validate | validate | blur | live |
data-theme | theme | auto | light | dark |
data-national-mode | nationalMode | boolean* |
data-show-dial-code | showDialCode | boolean* |
data-searchable | searchable | boolean* |
data-styles | styles | boolean* |
data-disabled | disabled | boolean* |
* Boolean attributes: "false" and "0" parse as false;
anything else (including empty) as true. Callback options (onChange etc.),
validator and labels have no data-attribute form — use the JS API.
Auto light/dark with the family's resolution order (<html data-theme> /
data-bs-theme / .dark/.light class →
prefers-color-scheme, re-resolved live; via VC.theme when the core is
loaded). Pin per instance with theme: 'light' | 'dark'. All colors are CSS custom
properties, defined on .vph, .vph-panel (and overridden under
[data-theme=dark]):
| Property | Light default | Dark default | Purpose |
|---|---|---|---|
--vph-accent | #5b5bd6 | #7b7bea |
Focus border, focus-visible outlines, selected option, search focus. |
--vph-bg | #ffffff | #1b1d24 |
Field and panel background. |
--vph-surface | #f2f2f5 | #272a33 |
Hover/active option background, country-button hover, search input. |
--vph-text | #1c1d21 | #e9eaf0 |
Primary text. |
--vph-muted | #72747e | #989aa6 |
Dial codes, caret, placeholders, empty-list text. |
--vph-faint | #e7e7ec | #31343f |
Borders and separators. |
--vph-danger | #e5484d | #f2555a |
Invalid border color. |
--vph-success | #1f9d5b | #4ccb8f |
Valid border color. |
--vph-ring | rgba(91,91,214,.16) | rgba(123,123,234,.24) |
Focus ring; auto-derived from the accent via color-mix (18%) where supported. |
--vph-shadow | 0 14px 36px rgba(24,25,32,.16), 0 3px 10px rgba(24,25,32,.08) | 0 14px 36px rgba(0,0,0,.5), 0 3px 10px rgba(0,0,0,.35) |
Dropdown panel shadow. |
--vph-radius | 10px | same | Field corner radius (panel uses a fixed 12px). |
--vph-font | system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif | same | Widget font stack. |
CSS isolation: the control renders as class="vph vc1" and all
structural rules ship salted (.vph.vc1 .vph-input { … }) so host design systems cannot
override the widget, while --vph-* variable definitions stay unsalted so
.vph { --vph-accent: … } page overrides keep working. Custom token:
PhoneInput.salt = 'acme' before the first instance; disable with
PhoneInput.salt = false. With the VC core loaded,
VC.config({ accent: '#b45309' }) themes the whole family at once.
Headless: pass styles: false (or set
PhoneInput.defaults.styles = false) and no CSS is injected — behavior, ARIA and the
.vph-* markup contract are kept; style them from your own CSS, starting from the
PhoneInput.css string if you like.
.vph[data-theme=dark].vph-invalid ← .vph-valid when valid (after blur)
.vph-country[aria-expanded] ← button: flag + dial + caret
.vph-flag > svg ← 20×15 flag
.vph-dial
.vph-caret
input.vph-input[type=tel]
input[type=hidden] ← only when opts.name given
.vph-sr[aria-live=polite] ← validation announcements
.vph-panel ← portaled to <body> while open
.vph-search > input[role=combobox]
.vph-list[role=listbox]
.vph-opt[role=option].is-active.is-selected
.vph-sep / .vph-empty
| Where | Key | Action |
|---|---|---|
| Country button | Enter / Space |
Toggle the dropdown (native button activation). |
ArrowDown / ArrowUp |
Open the dropdown. | |
| Dropdown open | ArrowDown / ArrowUp |
Move the active option down/up (clamped at the ends). |
Home / End |
Jump to first/last option (left to the caret when pressed inside the search box). | |
Enter |
Select the active country, close, refocus the tel input. | |
Escape |
Close and return focus to the country button (also handled at document level). | |
Tab |
Close without stealing focus back. | |
| printable characters | Filter via the search box (name, iso2 or dial-code prefix); without a search box, typeahead over country names (700 ms buffer). | |
| Tel input | character keys | Restricted to digits and + ( ) - . space; shortcuts with Ctrl/Cmd/Alt pass through untouched. |
<input type="tel">; autocomplete="tel" is preserved (or added when absent).aria-label ("Choose country: … +971"), aria-haspopup="listbox" and aria-expanded.aria-activedescendant (set on the search box — a role="combobox" with aria-autocomplete="list" and aria-controls — or on the list itself when searchable: false); options carry role="option" and aria-selected.aria-invalid on the input and announces labels.invalid through a polite live region — only after blur by default, so users aren't scolded mid-typing.:focus-visible outlines throughout; prefers-reduced-motion disables all transitions and animations.styles: false injects nothing — country detection,
as-you-type formatting, validation, the dropdown, ARIA and the stable
.vph-* class hooks all remain; you bring your own CSS. The demos above have
already injected the kit stylesheet into this page, so a headless instance here would
still match those rules — each demo below therefore runs inside its own
<iframe>, a genuinely clean document.
Raw browser rendering, zero CSS — the flag SVG, dial code, searchable dropdown and
+… auto-detection all still work.
new PhoneInput('#phone', {
country: 'gb',
styles: false // no CSS injected
})
The same hooks mapped to a warm fintech look with Tailwind @apply —
including the .vph-valid / .vph-invalid states after blur.
Loads cdn.tailwindcss.com only when you ask.
.vph { @apply inline-flex w-80 items-stretch overflow-hidden
rounded-xl border-2 border-amber-300 bg-white shadow-sm
focus-within:border-amber-500 focus-within:ring-4
focus-within:ring-amber-200/60; }
.vph.vph-invalid { @apply border-rose-400; }
.vph.vph-valid { @apply border-emerald-400; }
.vph-country { @apply flex items-center gap-1.5 bg-amber-50
px-3 text-sm cursor-pointer; }
.vph-flag svg { @apply block h-[15px] w-5 rounded-[2px]; }
.vph-dial { @apply font-semibold text-amber-700; }
.vph-input { @apply w-full border-0 bg-transparent px-3
py-2.5 text-sm outline-none; }
.vph-panel { @apply z-50 mt-1 w-72 overflow-hidden rounded-xl
border border-amber-200 bg-white shadow-2xl; }
.vph-search input { @apply w-full rounded-lg bg-amber-50
px-3 py-1.5 text-sm outline-none; }
.vph-list { @apply m-0 max-h-52 list-none overflow-y-auto p-1.5; }
.vph-opt { @apply flex items-center gap-2.5 rounded-lg
px-2.5 py-1.5 text-sm cursor-pointer; }
.vph-opt.is-active { @apply bg-amber-100/70; }
.vph-opt.is-selected { @apply font-semibold text-amber-700; }
.vph-opt-dial { @apply text-xs text-slate-400; }
.vph-sep { @apply my-1 h-px bg-amber-100; }
.vph-empty { @apply px-3 py-6 text-center text-slate-400; }
.vph-sr { @apply sr-only; }