Vanilla UI Kit Phone Input v1.0.0 · toast →

Phone numbers, one file.

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' })

Basic + live output

Type a number — the parsed value updates on every keystroke.

—
new PhoneInput('#basic', {
  country: 'us',
  onChange: v => show(v)   // {e164, national, country, valid}
})

Preferred countries

Pin the markets you serve to the top of the dropdown.

new PhoneInput('#preferred', {
  country: 'ae',
  preferredCountries: ['ae','sa','kw','qa','bh','om']
})

Auto-detect from paste

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

Validation states

validate: 'blur' (default) waits until you leave the field; 'live' judges every keystroke.

validity: —
new PhoneInput('#a', { validate: 'blur' })  // Maz-style
new PhoneInput('#b', { validate: 'live',
  onValidityChange: ok => … })

Inside a real form

Container mode builds the control and a hidden named input carrying the E.164 value.

submits: —
<div id="form-phone"></div>
new PhoneInput('#form-phone', { name: 'phone' })
// <input type="hidden" name="phone" value="+9715…">

National vs E.164 display

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

API reference

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.

Constructor & usage

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:

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.

Options

All options with their defaults, exactly as in PhoneInput.defaults (mutable — change it to set global defaults before constructing).

NameTypeDefaultDescription
countrystring'us' Initial ISO-3166 alpha-2 country. If unknown or excluded, falls back to the first allowed country.
preferredCountriesstring[][] iso2 codes pinned to the top of the dropdown, above a separator.
onlyCountriesstring[] | nullnull Whitelist of iso2 codes; all others are removed from the dropdown and auto-detection.
excludeCountriesstring[] | nullnull Blacklist of iso2 codes.
nationalModebooleantrue 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.
showDialCodebooleantrue Show +971 next to the flag in the country button.
searchablebooleantrue 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.
validatorfunction | nullnull fn({country, digits, e164}) → boolean — replaces the built-in national-length check (digits are national significant digits, trunk 0 stripped).
namestring | nullnull Adds a hidden input with this name carrying the E.164 value (skipped in anchor mode when the input already has this name).
disabledbooleanfalse 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.
stylesbooleantrue false = headless: no CSS injected; full behavior and markup contract kept.
labelsobject {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.
onChangefunction | nullnull fn({e164, national, country, valid}) on every edit — see Events.
onCountryChangefunction | nullnull fn(iso2) when the country switches (dropdown, auto-detect, setCountry, setValue).
onValidityChangefunction | nullnull 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.

Instance methods

All setters return the instance for chaining.

MethodReturnsDescription
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.

Statics & helpers

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.

HelperReturnsDescription
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

StaticType / ReturnsDescription
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.defaultsobject The live defaults object (see Options). Mutate before constructing, e.g. PhoneInput.defaults.styles = false.
PhoneInput.countriesarray The full data table — 242 records of {iso2, name, dialCode, order, lengths, pattern, areaCodes}.
PhoneInput.versionstring '1.0.0'.
PhoneInput.saltstring | false CSS isolation token (default 'vc1'). Set your own token or false to disable, before the first instance.
PhoneInput.cssstring (getter) The full stylesheet, rendered live with the current salt — a starting point for headless styling.
PhoneInput.displayName / .rootClass / .themeVars / .varScopesstring / 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.

Events & callbacks

The component does not dispatch custom DOM events — all notifications go through the three option callbacks:

CallbackPayloadFires
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.

Declarative init (data attributes)

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">
AttributeMaps to optionValue format
data-vphmarker (+ country if a value is given)empty, or an iso2
data-countrycountryiso2 (wins over the data-vph shorthand)
data-preferredpreferredCountriescomma-separated iso2 list
data-onlyonlyCountriescomma-separated iso2 list
data-excludeexcludeCountriescomma-separated iso2 list
data-namenamestring
data-placeholderplaceholderstring; the literal "false" means no placeholder
data-validatevalidateblur | live
data-themethemeauto | light | dark
data-national-modenationalModeboolean*
data-show-dial-codeshowDialCodeboolean*
data-searchablesearchableboolean*
data-stylesstylesboolean*
data-disableddisabledboolean*

* 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.

Theming

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]):

PropertyLight defaultDark defaultPurpose
--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-ringrgba(91,91,214,.16)rgba(123,123,234,.24) Focus ring; auto-derived from the accent via color-mix (18%) where supported.
--vph-shadow0 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-radius10pxsame Field corner radius (panel uses a fixed 12px).
--vph-fontsystem-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serifsame 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

Accessibility

WhereKeyAction
Country buttonEnter / Space Toggle the dropdown (native button activation).
ArrowDown / ArrowUp Open the dropdown.
Dropdown openArrowDown / 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 inputcharacter keys Restricted to digits and + ( ) - . space; shortcuts with Ctrl/Cmd/Alt pass through untouched.

Headless mode

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.

Unstyled (styles: false)

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
})

Bring your own design (Tailwind)

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; }