A fully customizable phone number input for Vue 3 with country selection, flags, masking, and localization. Built for flexibility and great DX.
npm i libphonenumber-js vue-tel-num-input
# or
npm i libphonenumber-js vue-tel-num-input
# or
pnpm i libphonenumber-js vue-tel-num-inputOpen the online Playground on StackBlitz
Try out the component live, tweak props, and experiment with integration directly in your browser.
View full documentation site — guides, API reference, and more examples.
- Country selector with searchable dropdown
- Multiple flag strategies: emoji / sprite / CDN / custom
- Masking (libphonenumber-js AsYouTypeor a custom mask)
- Localized placeholders (per-locale strings)
- Global (+1 234…) vs national ((234) …) formatting
- Slots for full UI customization (button, input, list items, search)
- TypeScript-first API (exports useful types)
- Performant lists (optional virtual scroll)
- Opt-in default styles, easy to replace
ℹ️ This component uses libphonenumber-js under the hood for phone number validation and formatting (international and national styles).
<script setup lang="ts">
import { ref } from "vue";
import VueTelNumInput, { type TelInputInitModel } from "vue-tel-num-input";
import "vue-tel-num-input/style.css";
import "vue-tel-num-input/flags.css";
const model = ref<TelInputInitModel>({});
</script>
<template>
  <VueTelNumInput v-model="model" default-country-code="US" />
</template>- Flexible by default: sensible defaults, but every piece is swappable.
- Composable: bring your data sources, flags, and masks.
- DX > ceremony: clear props, typed model, documented slots & events.
- Performance-aware: lazy assets, lazy api icons loading.
| Prop | Type | Default | Description | 
|---|---|---|---|
| size | "sm" | "md" | "lg" | "xl" | "xxl" | "lg" | Component sizing (affects row/item heights via CSS variables) | 
| disableSizing | boolean | false | Turn off built-in sizing classes | 
| displayName | "english" | "native" | "english" | Country name to display | 
| countryCodes | string[] | [] | Allowlist of ISO2 codes to show | 
| excludeCountryCodes | string[] | [] | Blocklist of ISO2 codes to hide | 
| autoDetectCountry | boolean | false | Detect user country on mount (best effort) | 
| defaultCountryCode | string | "US" | Initial country ISO2 | 
| disabled | boolean | false | Disable input | 
| silent | boolean | false | Suppress component warns | 
| initialValue | string | "" | Initial input value | 
| international | boolean | true | Format as international ( +XX) if true, national if false | 
| placeholder | string | Record<string,string> | "Enter phone number" | Static placeholder or locale map | 
| locale | string | - | Key to pick from placeholderobject | 
| flagSource | "emoji" | "sprite" | "cdn" | "emoji" | Strategy for flag rendering (emoji, sprite, CDN) | 
| itemHeight | number | size-based | Row height override in px | 
| animationName | string | fade | Animation name for <Transition>built-in Vue component | 
| Input options | |||
| input.clearOnCountrySelect | boolean | true | Clear input when selecting a new country | 
| input.focusAfterCountrySelect | boolean | true | Autofocus input after selecting a new country | 
| input.formatterEnabled | boolean | true | Enable libphonenumber formatting while typing | 
| input.lockCountryCode | boolean | false | Prevent user from removing/editing the country code | 
| input.maxLength | number | undefined | undefined | Optional character cap for input | 
| input.required | boolean | false | Default input requiredattribute | 
| Search options | |||
| search.hidden | boolean | false | Hide search bar in dropdown | 
| search.placeholder | string | Record<string,string> | undefined | Placeholder text or localized map | 
| search.locale | string | undefined | Key to pick from search.placeholdermap | 
| search.clearOnSelect | boolean | true | Clear search query after selecting a country | 
| search.autoFocus | boolean | true | Autofocus the search input when dropdown opens | 
| Prefix options | |||
| prefix.hidden | boolean | false | Hide prefix button entirely | 
| prefix.hideCode | boolean | false | Hide dialing code in prefix | 
| prefix.hideFlag | boolean | false | Hide flag in prefix | 
| prefix.hideChevron | boolean | false | Hide dropdown chevron | 
| prefix.hideCountryName | boolean | false | Hide country name in prefix | 
| List options | |||
| list.hidden | boolean | false | Hide country list (disables dropdown) | 
| list.hideCode | boolean | false | Hide dialing codes inside the list | 
| list.hideFlag | boolean | false | Hide flags inside the list | 
| list.hideCountryName | boolean | false | Hide country names inside the list | 
| list.returnToSelected | boolean | true | Scroll back to selected country when reopening dropdown | 
| list.itemsPerView | number | 5 | Max visible items in dropdown before scrolling | 
Flag strategy notes
emoji: lightweight, zero-network, varies by OS font rendering.
sprite: best for consistent visuals offline; bundle your sprite.
cdn: smallest package size; requires network & CORS-safe CDN.
This component uses typed object binding.
v-model works with a TelInputInitModel, giving you both the phone number and related country metadata.
<script setup lang="ts">
import { ref } from "vue";
import type { TelInputInitModel } from "vue-tel-num-input/types";
const model = ref<TelInputInitModel>({});
</script>
<template>
  <VueTelNumInput v-model="model" default-country-code="US" />
</template>type TelInputModel = {
  iso: string; // Selected country ISO2 (e.g. "US")
  name: string; // Country name (localized)
  code: string; // Country calling code (e.g. "+1")
  value: string; // Raw phone number string (user input)
  search: string; // Current search query in dropdown
  expanded: boolean; // Whether the country list is open
};This gives you full control over both value and UI state (selected country, search query, expanded state).
- The model is readonly from the outside: you should not manually assign values into it.
- Always initialize your ref with an empty object ({})— the component will populate and update it.
- Use it for reading only, all changes come from user interaction inside the component.
| Event | Payload | When | 
|---|---|---|
| update:modelValue | TelInputModel | After formatting/typing/country change. | 
| toggle | boolean | Dropdown open/close toggled. | 
| focus | void | Input focused. | 
| blur | void | Input blurred. | 
| Slot name | Purpose | 
|---|---|
| prefix:before | Before everything inside the country button. | 
| prefix:flag | Custom flag in the button. | 
| prefix:code | Custom code text ( +421). | 
| prefix:countryName | Custom country label. | 
| prefix:chevron | Chevron / indicator icon. | 
| prefix:after | After everything inside the button. | 
| input | Replace the <input>entirely. | 
| body:search | Replace the whole search container. | 
| search:icon | Magnifier icon in search. | 
| search:input | Replace search <input>. | 
| item:before | Before each list row. | 
| item:flag | Flag in list rows. | 
| item:code | Code in list rows. | 
| item:countryName | Country name in list rows. | 
| item:after | After each list row. | 
The component exposes an API that can be accessed via ref.
This allows you to programmatically control the dropdown, formatting, and access internal refs.
export type VueTelNumInputExpose = {
  /** Open/close the country dropdown programmatically */
  switchDropdown: (value?: boolean) => void;
  /** Select a specific country programmatically */
  selectItem: (data: Country) => void;
  /** Force re-formatting of the current phone number value */
  formatNow: () => void;
  /** References to DOM elements */
  inputEl: HTMLInputElement | null;
  searchEl: HTMLInputElement | null;
  telNumInputEl: HTMLElement | null;
  /** Current user country (if auto-detection is enabled) */
  country: string | null;
  /** Trigger user country detection manually */
  requestUserCountry: () => Promise<string | null>;
};<script setup lang="ts">
import { ref, onMounted } from "vue";
import VueTelNumInput from "vue-tel-num-input";
import type { VueTelNumInputExpose } from "vue-tel-num-input";
const telRef = ref<VueTelNumInputExpose | null>(null);
onMounted(() => {
  // Open dropdown programmatically
  telRef.value?.switchDropdown(true);
  // Format the current phone number immediately
  telRef.value?.formatNow();
});
const detectCountry = async () => {
  const iso = await telRef.value?.requestUserCountry();
  console.log("Detected country:", iso);
};
</script>
<template>
  <VueTelNumInput ref="telRef" />
  <button @click="detectCountry">Detect country</button>
</template><VueTelNumInput
  v-model="phone"
  :country-codes="['US', 'CA', 'GB', 'SK', 'PL']"
/><VueTelNumInput v-model="phone" display-name="native" :flag-source="'cdn'" /><VueTelNumInput v-model="phone">
  <template #prefix:flag>
    <MyFlag :iso="model.iso" />
  </template>
  <template #prefix:code>
    <span class="code">Dial {{ model.code }}</span>
  </template>
</VueTelNumInput><VueTelNumInput
  v-model="phone"
  :placeholder="{ en: 'Phone number', sk: 'Telefónne číslo' }"
  locale="sk"
/>// prop input.formatterEnabled=true enables AsYouType internallyOR bring your own mask by replacing the input slot and binding back to v-model.
The library exports type for model ref:
import type { TelInputModel } from "vue-tel-num-input";
// Example TelInputModel shape:
type TelInputModel = {
  iso: string; // ISO2
  name: string; // country label (native/english)
  code: string; // '+421'
  value: string; // input value
  search: string; // dropdown search query
  expanded: boolean;
};If you’re building wrappers, re-export these types from your package so users don’t need to reach inside your internals.
| Var | Default | Notes | 
|---|---|---|
| --tel-input-height | 40px | Overall control height | 
| --tel-input-border-radius | 6px | Corner radius for head & dropdown | 
| --tel-input-font-size | 14px | Base font size for component | 
| --tel-input-padding-x | 12px | Horizontal padding for the input | 
| --tel-input-icon-size | 12px | Size for chevrons/search/flag placeholders | 
| --tel-scrollbar-width | 6px | Dropdown scrollbar width | 
| --tel-scrollbar-thumb | #bbb | Dropdown scrollbar thumb color | 
| --tel-scrollbar-track | #f9f9f9 | Dropdown scrollbar track color | 
| --tel-scrollbar-radius | 12px | Dropdown scrollbar thumb radius | 
| --tel-input-prefix-padding-x | 12px | Horizontal padding inside the country button | 
| --tel-input-prefix-gap | 8px | Gap between flag/code/name in the button & list items | 
| --tel-input-chevron-transition-func | ease | Timing function for chevron rotation | 
| --tel-input-transition-duration | 0.3s | Shared transition duration | 
| --tel-input-chevron-transition-delay | 0s | Delay for chevron transition | 
| --tel-input-chevron-transition-prop | transform | Transitioned property for chevron | 
| --tel-input-input-width | 200px | Width of the text input | 
| --tel-input-input-bg | #fff | Input background | 
| --tel-input-input-color | #333 | Input text color | 
| --tel-input-body-border | 1px solid #ccc | Border around the dropdown panel | 
| --tel-input-body-bg | #f9f9f9 | Dropdown background | 
| --tel-input-search-outline | none | Outline for the search input | 
| --tel-input-search-border | none | Border for the search input | 
| --tel-input-search-icon-color | #333 | Color of the search icon | 
| --tel-input-search-icon-margin-x | 12px | (Preferred) Left margin of search icon | 
| --tel-input-search-icon-margin-x | 12px | (Current code uses this – likely a typo) | 
| --tel-item-padding-x | 12px | Horizontal padding for each country row | 
| --tel-input-body-item-border | 1px solid #eee | Divider between country rows | 
| --tel-input-body-item-bg | #fff | Country row background | 
| --tel-input-body-item-color | #333 | Country row text color | 
| --tel-input-transition-func | ease | Shared transition timing function | 
| --tel-input-transition-delay | 0s | Shared transition delay | 
| --tel-input-transition-prop | background-color, color | Shared transitioned properties | 
| --tel-input-body-item-hover-bg | #eee | Hover background for country rows | 
| --tel-input-body-item-hover-color | #333 | Hover text color for country rows | 
| --tel-input-body-item-hover-cursor | pointer | Cursor on hover for country rows | 
| --tel-input-body-item-selected-bg | #ddd | Selected row background | 
| --tel-input-body-item-selected-color | #333 | Selected row text color | 
You can also disable built-in sizing with disableSizing and style from scratch.
- Dropdown with complete slot coverage ✅
- Multiple flag strategies ✅
- Global vs national formatting toggle ✅
- Search UX polish (clear icon, keyboard nav) ⏳
- Fully documented events & accessibility pass ⏳
- Tests 🤯
Contributions welcome — see below 🙏.
- Keyboard navigation in dropdown (planned)
- ARIA attributes on toggle and list (planned)
- Focus management on open/close (partial; improvements planned)
If accessibility is critical in your project, review current behavior and consider contributing improvements — happy to collaborate.
Contributions are very welcome! You can help by fixing bugs, improving docs, or adding features.
- Fork the repo and create a new branch
- npm ito install dependencies
- npm watch:buildto build the project JIT
- npm linkin directory folder
- npm link vue-tel-num-inputin your project directory
- Commit your changes and push your branch 😍
- Create Pull-Request
- Thank you
For UI changes please include screenshots or gifs so it’s easy to review 🥹
👉 Bug reports and feature requests should be submitted as GitHub Issues.
This component is currently in beta. The API and behavior may still change before a stable release.
If you encounter any bugs, unexpected behavior, or have feature requests: 👉 please open an issue on GitHub
Your feedback will help improve and stabilize the component for production use.
- CDN flags require a CORS-safe provider; otherwise use emojiorsprite.
- autoDetectCountryis best-effort; always set- defaultCountryCodeas fallback.
- When formatterEnabledistrue, manual cursor jumps can occur with some masks—test your locales and adjust strategy if needed.
MIT © 2025 Mark Minerov
