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-input
Open 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
AsYouType
or 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 placeholder object |
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 required attribute |
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.placeholder map |
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: CountryConfig) => 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 internally
OR bring your own mask by replacing the input
slot and binding back to v-model
.
The library exports useful types:
import type {
TelInputModel,
CountryConfig,
FlagConfig,
} 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 i
to install dependenciesnpm watch:build
to build the project JITnpm link
in directory foldernpm link vue-tel-num-input
in 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
emoji
orsprite
. autoDetectCountry
is best-effort; always setdefaultCountryCode
as fallback.- When
formatterEnabled
istrue
, manual cursor jumps can occur with some masks—test your locales and adjust strategy if needed.
MIT © 2025 Mark Minerov