ATelInput
An international telephone input that gets out of the way. The field starts as a single clean input — no picker, no clutter — and reveals the country flag the moment your number's dial code is recognised. Numbers validate in real time against libphonenumber-js, the picker is a popover on desktop and a bottom-sheet on mobile, and the whole thing plugs straight into VeeValidate + Zod with built-in support for async server-side checks.
v-model:phone is the digits-only national number. v-model:country is the dial-code number (20 for Egypt, 44 for the UK, 1 for the NANP block, null for none).
Live · ATelInput
What's in the box
- Universal country detection — debounced parse against the full libphonenumber metadata (~250 countries), with a priority chain (env hint → current → recents → popular shortlist → all countries). Works for international format (
+201066105963) AND local format (01066105963resolves to Egypt even when the env hint is Saudi). - libphonenumber-js validation — seven failure reasons, format hint, E.164 output, all reactive.
- Responsive picker — popover on desktop, vaul-vue bottom-sheet on mobile, sticky-safe scroll lock on both (the page underneath never scrolls; the picker's inner list does).
- Form-library ready — controlled
errorprop,@blurevent,useTelField()composable for VeeValidate,zPhone()factory for Zod, plus an in-field spinner for async server-side validation. - Two binding contracts — single
v-model(E.164 string, drops into VeeValidate's<Field v-slot="{ field }">viav-bind="field") or splitv-model:phone+v-model:country. Both stay in sync. - i18n + RTL — country names via
Intl.DisplayNames, numerals localised in the format hint, RTL inherited from the page, alternative numerals (Arabic-Indic, Persian, Devanagari, Bengali) folded to ASCII on input. - Headless slots for every visual region — trigger, chevron, flag, item, search, hint, error, the lot.
- Efficient by default — REST Countries fetch + IP geolocation request deduped to one network call per page across every
<ATelInput>/<ACountrySelect>/useTelField()/zPhone()instance. LRU-cached matcher. - SSR-safe — country detection runs after mount, hydration is clean.
- TypeScript-first — every prop, slot, and event typed; web-types ship for JetBrains IDEs.
Install
$pnpmadd@alikhalilll/a-tel-inputSetup
The shipped CSS is self-contained — design tokens + utility classes are pre-compiled. Import the stylesheet once and the field renders themed out of the box (no Tailwind config, no @theme block).
Nuxt 3 / 4
For auto-imports (use <ATelInput> / <ACountrySelect> / <ACountryFlag> with no import), also register the bundled module:
Vue + Vite
For unplugin-vue-components auto-resolve, drop in the shipped resolver:
Dark mode
Toggle class="dark" (or "light") on <html> — every component inherits via CSS variables.
Theming tokens, UnoCSS interplay, monorepo CSS gotcha, full public API — see the UI overview.
Usage
Type +447911123456, 01066105963, or paste any well-formed international number — the flag trigger reveals at the end of the field with the detected country, the dial code appears as a prefix inside the input, and phone normalises to the national significant number (7911123456, 1066105963).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:phone | string | '' | Digits-only national number — no leading +, no dial code. |
v-model:country | number | null | null | Dial number, e.g. 20 (Egypt), 44 (UK), 1 (NANP). |
name | string | — | Forwarded to the inner <input name=""> for native form / form-library integration. |
error | string | null | — | Externally controlled error message. When non-empty, overrides internal validation and forces the error state. |
validating | boolean | false | true while an async validation is in flight. Renders a spinner inside the field (doesn't disable it). |
validateOn | 'change' | 'blur' | 'eager' | 'change' | When to surface validation in the UI. 'blur' is form-library friendly. |
placeholder | string | 'Phone number' | Falls back to the country's example number when empty. |
disabled | boolean | false | |
loading | boolean | false | Disables interaction. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' (43 px) | Height, padding, text size. |
allowedDialCodes | string[] | all | Whitelist of dial digits (no +). Disallowed countries render disabled. |
showValidation | boolean | false | Light up the field's validation styling — coloured border + ring + error message — when invalid. Off by default. |
showValidationIcon | boolean | false | Show the green check / red alert icon at the end of the field. Off by default; independent of showValidation. |
detectCountry | 'auto' | 'locale' | 'none' | 'auto' | Strategy for the silent environment lookup (parsing hint). |
defaultCountry | string | '' | Initial country. Accepts a dial number string ('20') or ISO2 ('EG'). When set, picker is visible at mount. |
detectFromInput | boolean | true | Default mode. Set false for the legacy always-visible picker. |
detectDebounceMs | number | 150 | Debounce window for input-driven detection (ms). |
ipEndpoint | string | 'https://ipapi.co/json/' | Override the IP geolocation endpoint. |
searchPlaceholder | string | 'Search country or +code…' | Picker search placeholder. |
emptyText | string | 'No countries found.' | Shown when search yields no results. |
loadingText | string | 'Loading countries…' | Shown while the country list loads. |
errorMessages | Partial<Record<PhoneValidationReason, string>> | English defaults | Override the validation error labels. |
dir | 'ltr' | 'rtl' | 'auto' | 'auto' (inherits) | Text direction. 'auto' / omitted inherits from the page; 'ltr' / 'rtl' force it. |
locale | string | — | BCP-47 locale. Localises country names (Intl.DisplayNames) and the format-hint numerals. |
messages | TelInputMessages (partial) | English defaults | One bag for every UI string — picker, error labels, and a11y labels. See Internationalization. |
class | HTMLAttributes['class'] | — | Merged into the outer wrapper. |
Exposed via template ref
Sizes
Live · All five sizes
Five sizes — xs 28 / sm 36 / md 43 default / lg 52 / xl 60 px. See Size scale for the shared exported maps.
Validation
Live · Validation reasons
Try: type a couple digits (too_short) · paste a letter (phone_has_non_digits) · type 15 digits (too_long) · type a real number to see the green ring + checkmark.
Validation runs on every keystroke via usePhoneValidation() (a libphonenumber-js wrapper) and produces:
Localise the messages with errorMessages:
Form integration
The component supports two binding contracts:
- Single
v-modelcarrying the canonical E.164 string — works directly with VeeValidate's<Field v-slot="{ field }">, native<form>submission, and anyv-model="phoneE164"consumer. - Split
v-model:phone+v-model:country— when you want the raw digits and the dial code as separate values. Stays in sync with the single-string contract; pick whichever fits.
Two subpath entries also ship for first-class VeeValidate + Zod integration, including async / server-side validation:
$pnpmaddvee-validate@vee-validate/zodzodDrop-in <Field v-slot="{ field, errors }"> — v-bind="field" just works
Use VeeValidate's slot-style <Field> exactly the way you would with a native <Input>. The component's default v-model is the E.164 string, so Vue auto-spreads field.modelValue, field['onUpdate:modelValue'], field.name, and field.onBlur straight through:
That's the whole integration. No useTelField(), no manual handleBlur, no extra glue.
useTelField() — when you need async / server-side validation
Live · VeeValidate + Zod + server-side check
Type a number and blur the field. Any number containing 123456789 is rejected by the fake server (700 ms latency) — watch the spinner inside the field while the request is in flight. The form-level Zod schema catches malformed numbers before the network call.
useTelField(name, options) (from @alikhalilll/a-tel-input/vee-validate) owns the two v-models (phone + country), composes them into an E.164 string for VeeValidate's schema, and returns a ready-to-bind prop bag. Pair it with zPhone() for a Zod schema that shares the same libphonenumber-js engine the component uses — schema and field can never disagree.
Server-side checks ride the schema via z.refine(async) — vee-validate ignores field-level rules when useForm has a validationSchema, so the refine is what handleSubmit awaits and what drives useTelField's validating ref (→ in-field spinner).
Anatomy
errorprop — externally controlled error message. When set, it forces the field into the error state and replaces internal libphonenumber validation. Wire it from any source — VeeValidate, Zod, a server response, a custom validator.validatingprop —truewhile an async rule is in flight. Renders a small spinner inside the field and setsaria-busy="true". Doesn't disable the input (useloadingfor that).validateOnprop —'change'(default, current behaviour),'blur'(stays idle until first blur — form-library friendly), or'eager'(no typing pause).nameprop — forwards to the inner<input name="">for native<form>submission.@blur/@focusemits — mirror the inner input's native events.- Exposed methods —
tellRef.value?.focus(),.blur(),.select()for imperative focus management after submit-fail.
Zod schema shapes
zPhone() returns a z.ZodType<string> that validates an E.164 string:
Or use zPhoneObject() when you want to validate the { phone, country } shape directly:
Native HTML forms
name is forwarded to the inner <input>, so FormData picks the value up automatically. The submitted value is the digits-only national number; compose the E.164 via usePhoneValidation() when needed.
Country detection
From the environment (silent)
On mount, the component runs an IP → timezone → locale chain (default strategy 'auto') and stores the result as a hint for the input parser. It does not auto-fill the picker.
detectCountry value | Behaviour |
|---|---|
'auto' (default) | IP → timezone → locale → default |
'locale' | Skip IP. Timezone → locale → default |
'none' | Use defaultCountry immediately |
Run detection imperatively from anywhere:
From user input (default)
Live · ATelInput · detect-from-input
Try typing or pasting +447911123456, 447911123456, 201001234567, or 16175551234 for international format. Or try a local-format Egyptian number like 01066105963 — the picker reveals once the digits form a valid number for your silently-detected region.
detect-from-input is true by default. The picker is hidden until typing or pasting matches a known dial code — debounced by detectDebounceMs (default 150 ms). On match, the picker reveals and phone is normalised to the national significant number (dial code + national prefix stripped).
Behaviour:
- Local formats like Egyptian
01066105963or UK07911123456work — once the typed number is fully valid for the hinted country (from the environment chain ordefaultCountry), the picker reveals. - Multi-country dial codes (
+1NANP,+7RU/KZ) tiebreak by recents → alphabetical. - A manual pick freezes detection. Clearing the input resets and re-arms it.
- Known limitation: raw-digit input is fundamentally ambiguous. Typing
415resolves to Switzerland (+41 5…), not the US area code. For region-locked apps, passdefault-countryinstead.
Typing-pause UX
While the user is mid-burst, the component holds back validation styling and shows a small spinner in the picker slot — both unblock once detectDebounceMs settles. After a failed detection (no dial code recognised), the picker becomes visible with no country selected so the user can pick manually instead of being stranded.
- Spinner during the debounce window — replaceable via the
#detectingslot. It only appears during the first detection attempt; once the picker has rendered (success or revealed-after-miss) the spinner stops re-flashing on every keystroke. - Validation gated by
hasFinishedTyping— field tinting (showValidation), the validation icon (showValidationIcon), thearia-invalidattribute, and the error message all stay neutral until the debounce settles. The rawvalidationStateis still exposed via the template ref for consumers that want eager state. - Picker reveals after a no-match attempt — driven by an internal
detectionAttemptedflag. The picker won't disappear again until the input is cleared. - Programmatic
v-model:phonechanges bypass the gate — a parent settingphoneis a committed value, not active typing, so validation surfaces immediately.
Exposed via template ref alongside the existing validation / validationState / required / selectedDialCode: visibleValidationState, isDetecting, hasFinishedTyping, detectionAttempted.
Internationalization
ATelInput is built for non-English, RTL, and non-ASCII-numeral users.
RTL
Live · RTL layout
Wrapped in <div dir="rtl">. The phone field row keeps its LTR order — dial prefix, digits, then flag — while the helper text and the country picker follow RTL. Try typing the Arabic-Indic digits ٠١٠٦٦١٠٥٩٦٣ — they normalise to ASCII.
The component is direction-aware. Omit dir (or pass 'auto') and direction inherits from
the page — wrap the component in <div dir="rtl"> or set <html dir="rtl">. Pass
dir="ltr" / dir="rtl" to force a direction regardless of the page.
The field row itself always stays left-to-right — the dial-code prefix, the digits, and the flag trigger keep the same order in every direction, because a phone number is inherently LTR content. What follows the page direction is the surrounding chrome: the helper/error line aligns to the page direction, and the country-picker popover (its search bar and list rows) mirrors. So an RTL page gets correctly-aligned Arabic helper text and a mirrored picker, without scrambling the phone field.
Alternative numerals
Digits entered in another script are accepted and normalised to ASCII — typing the
Arabic-Indic ٠١٠٦٦١٠٥٩٦٣ or the Persian ۰۹۱۲۳۴۵۶۷۸ is the same as typing the ASCII
equivalent. Detection, validation, and v-model:phone always work with ASCII digits.
Supported systems: Arabic-Indic (٠-٩), Extended/Eastern Arabic — Persian & Urdu (۰-۹),
Devanagari, and Bengali.
Locale & messages
Live · Arabic locale + messages
locale="ar" localises country names via Intl.DisplayNames and the format hint's numerals; :messages localises every UI string. Open the picker — country names render in Arabic and search matches both Arabic and English spellings.
Pass a locale and a messages bag to fully localise the component:
localelocalises country names viaIntl.DisplayNames(search matches both the localised and English spelling) and renders the format-hint numerals in that locale.messagesbundles every UI string — picker labels, validation errors, and screen-reader labels — into one prop. Every key is optional and falls back to its English default.- The individual props (
searchPlaceholder,emptyText,loadingText,errorMessages) still work and take precedence over the matchingmessageskey.
Accessibility
The phone input carries an aria-label (messages.phoneInputLabel), aria-invalid when
the number fails validation, and an aria-describedby pointing at the live helper line.
The hint and error share an aria-live="polite" region, so screen readers announce
validation changes. The country trigger and every list option are keyboard-reachable and
labelled.
Restricting countries
Live · allowed-dial-codes restriction
Restricted to ['20', '966', '971'] — Egypt, Saudi Arabia, UAE. Open the picker — every other country is greyed out and unclickable.
Disallowed countries still render in the picker but as disabled rows. The whitelist surfaces in the Suggested group at the top so the user doesn't scroll.
Theming
Live · Theme gallery — every preset is a pure CSS-variable rewrite
Click any preset to apply it. The same component, same props — only the --ak-ui-* variables change. Copy the snippet below into your own stylesheet to use it.
To apply globally, drop the snippet into your global CSS. To scope per-section, replace the selector with any class on a wrapper element — the popover portal still inherits the variables via the CSS cascade.
The component is themed entirely with the shared --ak-ui-* CSS variables — no component props, no rebuild. Three placement patterns:
Values are HSL triplets (no hsl(…) wrapper). Full token list and recipes live in the library theming guide.
Brand-color via a single hue
Live · Brand-color picker — dial in your hue, get a matching theme
The whole palette is derived from a single hue. Slide to see the input + picker reskin in real time. The Code tab shows the live theme object you'd bind to :style.
Vary lightness on one hue: 7% (background) → 9% (popover) → 14% (muted) → 18% (border) → 22% (accent) → 60%+ (ring). The component handles the rest.
Day / night
Live · Day / night toggle via the `.dark` class
No JS theme rewrites — the lib already ships :root, .light {…} and .dark {…} blocks. Flip the class on a wrapper and the whole component switches modes.
The lib ships both .light and .dark blocks — toggle the class on <html> (or any wrapper). Portaled popovers/drawers inherit via the cascade.
Multi-tenant
Live · Multi-tenant — same component, two themes, one page
Two tenants render side-by-side. Each is wrapped in its own class with its own variables — proving the theme is scoped, not global, and that popover portals inherit per-tenant.
Acme Inc.
Nova Labs
Radius variants
--ak-ui-radius is consumed directly (no Tailwind token):
Full customisation
Cream pill · circular flag · phone-icon suffix
Warm light-mode theme, pill radius, and a borderless circular-flag trigger. Big bold label above. The phone icon on the right is a #suffix slot.
Three customisation vectors — stack any combination:
Slots
| Slot | Scope | Replaces |
|---|---|---|
prefix | — | Content at the start of the field. |
suffix | { validationState, validation } | Content at the end, after the flag trigger. |
trigger | { selectedCountry, open, sizeClasses } | Entire country picker trigger. |
chevron | { open } | Just the chevron icon. |
selected-flag | { country, open } | Selected-state label rendered inside the trigger only. |
item-flag | { country } | Flag rendered for each popover option row only. |
flag | { country, context: 'trigger' | 'item' } | Legacy unified flag slot — fires for both locations¹. |
search | { value, setValue, isSearching } | Entire search bar. |
search-icon | — | Just the leading search icon. |
loading | — | Picker loading state. |
empty | { query } | Empty / no-results state. |
detecting | — | Spinner shown in the picker slot during the typing-pause debounce window. |
group-header | { label, group: 'suggested' | 'all' } | Section headers in the picker. |
item | { country, selected, disabled, select } | Entire row in the country list. |
item-check | { country } | Right-side check icon for selected row. |
valid-icon | — | Green check shown when valid. |
error-icon | { reason } | Warning icon shown when invalid. |
hint | { country, formatHint, example } | Helper line shown below when empty. |
error | { message, reason, validation } | Error message shown below when invalid. |
¹ Prefer selected-flag / item-flag over the legacy unified flag slot — they
target one location at a time so a trigger restyling doesn't bleed into the popover
list. flag is kept as a back-compat fallback (and is what fires when neither
of the dedicated slots is provided).
Data props
| Prop | Type | Replaces |
|---|---|---|
flagUrl | (iso2, width) => string | Default flagcdn.com URL builder. |
countries | CountryOption[] | Internal REST Countries fetch (curated or offline lists). |
searcher | (query, country) => boolean | Default substring match. Implement fuzzy / starts-with / etc. |
detector | (opts) => Promise<string | null> | The environment chain. Return null to fall through. |
errorMessages | Partial<Record<PhoneValidationReason, string>> | Error labels (for i18n). |
kbdOpen / kbdClose | string | null | The ⌘K / Esc keyboard hints. null to hide. |
Class props
class, fieldClass, inputClass, contentClass, popoverClass, drawerClass, hintClass, errorClass. Each is merged via tailwind-merge so you only override the bits you want.
Live gallery
Banking · phone-verification icons · indigo theme
A phone-verification field for a banking flow. Smartphone prefix signals "phone input", BadgeCheck / BadgeAlert for validation match the identity-verification context (the number is part of who-you-are, not a secret to hide).
Playful · sunset theme · pill radius · xl size
Marketing-friendly: warm pill, big xl size, smile suffix, party-popper / frown validation, sparkle check on the selected row in the picker.
What's your number?
We'll only use it to text the confirmation code.
Minimal · monochrome · xs size · ISO2-only trigger
Dense and quiet. Trigger shows only the ISO2 code + a tiny chevron — no flag, no dial code. Keyboard hints hidden via :kbd-open="null".
Stacking every vector — curated countries, hi-res flags, custom searcher, custom detector, slot overrides:
Everything customized · reference example
Curated 7-country list, hi-res flags, name-starts-with searcher, server-side detector (always Egypt), plus eight slot overrides — every single visible region replaced.
Composing your own variant
Live · Custom layout (composed from primitives)
Recombined primitives: full-width country trigger on top, a plain national-number <input> below, E.164 chip on the right.
Every primitive, composable, and helper is re-exported — fork-free composition: