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

statejson

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 (01066105963 resolves 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 error prop, @blur event, 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 }"> via v-bind="field") or split v-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-input
ts
import { ATelInput } from '@alikhalilll/a-tel-input';

Setup

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

ts
// nuxt.config.ts
export default defineNuxtConfig({
  css: ['@alikhalilll/a-tel-input/styles.css'],
});

For auto-imports (use <ATelInput> / <ACountrySelect> / <ACountryFlag> with no import), also register the bundled module:

ts
modules: ['@alikhalilll/a-tel-input/nuxt'],

Vue + Vite

ts
// main.ts
import '@alikhalilll/a-tel-input/styles.css';

For unplugin-vue-components auto-resolve, drop in the shipped resolver:

ts
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import UiResolver from '@alikhalilll/a-tel-input/resolver';

export default { plugins: [Components({ resolvers: [UiResolver()] })] };

Dark mode

Toggle class="dark" (or "light") on <html> — every component inherits via CSS variables.

ts
// nuxt.config.ts — locked dark
app: { head: { htmlAttrs: { class: 'dark' } } },

Theming tokens, UnoCSS interplay, monorepo CSS gotcha, full public API — see the UI overview.

Usage

vue
<script setup lang="ts">
import { ref } from 'vue';
import { ATelInput } from '@alikhalilll/a-tel-input';

const phone = ref('');
const country = ref<number | null>(null);
</script>

<template>
  <ATelInput v-model:phone="phone" v-model:country="country" show-validation />
</template>

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

PropTypeDefaultDescription
v-model:phonestring''Digits-only national number — no leading +, no dial code.
v-model:countrynumber | nullnullDial number, e.g. 20 (Egypt), 44 (UK), 1 (NANP).
namestringForwarded to the inner <input name=""> for native form / form-library integration.
errorstring | nullExternally controlled error message. When non-empty, overrides internal validation and forces the error state.
validatingbooleanfalsetrue 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.
placeholderstring'Phone number'Falls back to the country's example number when empty.
disabledbooleanfalse
loadingbooleanfalseDisables interaction.
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md' (43 px)Height, padding, text size.
allowedDialCodesstring[]allWhitelist of dial digits (no +). Disallowed countries render disabled.
showValidationbooleanfalseLight up the field's validation styling — coloured border + ring + error message — when invalid. Off by default.
showValidationIconbooleanfalseShow 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).
defaultCountrystring''Initial country. Accepts a dial number string ('20') or ISO2 ('EG'). When set, picker is visible at mount.
detectFromInputbooleantrueDefault mode. Set false for the legacy always-visible picker.
detectDebounceMsnumber150Debounce window for input-driven detection (ms).
ipEndpointstring'https://ipapi.co/json/'Override the IP geolocation endpoint.
searchPlaceholderstring'Search country or +code…'Picker search placeholder.
emptyTextstring'No countries found.'Shown when search yields no results.
loadingTextstring'Loading countries…'Shown while the country list loads.
errorMessagesPartial<Record<PhoneValidationReason, string>>English defaultsOverride the validation error labels.
dir'ltr' | 'rtl' | 'auto''auto' (inherits)Text direction. 'auto' / omitted inherits from the page; 'ltr' / 'rtl' force it.
localestringBCP-47 locale. Localises country names (Intl.DisplayNames) and the format-hint numerals.
messagesTelInputMessages (partial)English defaultsOne bag for every UI string — picker, error labels, and a11y labels. See Internationalization.
classHTMLAttributes['class']Merged into the outer wrapper.

Exposed via template ref

ts
const tellRef = ref<InstanceType<typeof ATelInput>>();
tellRef.value.validation; // PhoneValidationResult — reactive
tellRef.value.required; // PhoneRequiredInfo | null — example, length range, format hint
tellRef.value.selectedDialCode; // '+20' | null
tellRef.value.validationState; // 'idle' | 'valid' | 'error'

tellRef.value.focus(); // imperative focus management
tellRef.value.blur();
tellRef.value.select();

Sizes

Live · All five sizes

xs · 28px
sm · 36px
md · 43px
lg · 52px
xl · 60px

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.

statejson

Validation runs on every keystroke via usePhoneValidation() (a libphonenumber-js wrapper) and produces:

ts
interface PhoneValidationResult {
  ok: boolean;
  reason: PhoneValidationReason | null;
  country: { iso2: string; dial_code: string } | null;
  phone: { raw: string | null; digits: string };
  full_phone: string | null; // E.164, e.g. '+201066105963'
  required: PhoneRequiredInfo | null;
}

type PhoneValidationReason =
  | 'missing_country'
  | 'country_not_supported'
  | 'phone_has_non_digits'
  | 'too_short'
  | 'too_long'
  | 'invalid_phone'
  | 'parse_failed';

Localise the messages with errorMessages:

vue
<ATelInput
  v-model:phone="phone"
  v-model:country="country"
  :error-messages="{
    too_short: 'الرقم قصير جدًا',
    invalid_phone: 'الرقم غير صحيح',
  }"
  show-validation
/>

Form integration

The component supports two binding contracts:

  • Single v-model carrying the canonical E.164 string — works directly with VeeValidate's <Field v-slot="{ field }">, native <form> submission, and any v-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/zodzod

Drop-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:

vue
<script setup lang="ts">
import { useForm, Field as VeeField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
import { ATelInput } from '@alikhalilll/a-tel-input';
import { zPhone } from '@alikhalilll/a-tel-input/zod';

const { handleSubmit } = useForm({
  validationSchema: toTypedSchema(z.object({ phone: zPhone() })),
});
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <VeeField v-slot="{ field, errors }" name="phone">
      <label for="phone">Phone</label>
      <ATelInput
        id="phone"
        v-bind="field"
        :error="errors[0]"
        :aria-invalid="!!errors.length"
        default-country="SA"
        show-validation
      />
    </VeeField>
    <button type="submit">Submit</button>
  </form>
</template>

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

ts
import { useTelField } from '@alikhalilll/a-tel-input/vee-validate';
import { zPhone } from '@alikhalilll/a-tel-input/zod';
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const phoneSchema = zPhone().refine(
  async (value) => {
    if (!value) return true;
    const { exists } = await $fetch('/api/phone/exists', { query: { phone: value } });
    return !exists;
  },
  { message: 'This phone number is already registered.' }
);

const { handleSubmit } = useForm({
  validationSchema: toTypedSchema(z.object({ phone: phoneSchema })),
});

const { phone, country, error, handleBlur, fieldProps, validating } = useTelField('phone', {
  validateOn: 'blur',
  defaultCountry: 'SA',
});
vue
<ATelInput
  v-model:phone="phone"
  v-model:country="country"
  v-bind="fieldProps"
  :error="error"
  :validating="validating"
  show-validation
  @blur="handleBlur"
/>

Anatomy

  • error prop — 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.
  • validating proptrue while an async rule is in flight. Renders a small spinner inside the field and sets aria-busy="true". Doesn't disable the input (use loading for that).
  • validateOn prop'change' (default, current behaviour), 'blur' (stays idle until first blur — form-library friendly), or 'eager' (no typing pause).
  • name prop — forwards to the inner <input name=""> for native <form> submission.
  • @blur / @focus emits — mirror the inner input's native events.
  • Exposed methodstellRef.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:

ts
z.object({ phone: zPhone() }); // input: '+201066105963'
z.object({ phone: zPhone({ country: 'SA' }) }); // input: national digits, validated as SA
z.object({ phone: zPhone({ allowedDialCodes: ['20', '966'] }) }); // restrict to EG + SA

Or use zPhoneObject() when you want to validate the { phone, country } shape directly:

ts
import { zPhoneObject } from '@alikhalilll/a-tel-input/zod';

const schema = z.object({
  contact: zPhoneObject({ requiredMessage: 'Phone number is required.' }),
});

Native HTML forms

vue
<form>
  <ATelInput v-model:phone="phone" v-model:country="country" name="phone" />
</form>

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 valueBehaviour
'auto' (default)IP → timezone → locale → default
'locale'Skip IP. Timezone → locale → default
'none'Use defaultCountry immediately

Run detection imperatively from anywhere:

ts
import { detectCountry } from '@alikhalilll/a-tel-input';

const iso2 = await detectCountry({ strategy: 'auto', defaultCountry: 'EG' });

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.

statejson

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

vue
<!-- Default — picker hidden until detected -->
<ATelInput v-model:phone="phone" v-model:country="country" show-validation />

<!-- Variants -->
<ATelInput default-country="20" />
<!-- Pre-filled picker -->
<ATelInput v-model:country="myInitial" />
<!-- Pre-filled via v-model -->
<ATelInput :detect-from-input="false" />
<!-- Legacy always-visible picker -->

Behaviour:

  • Local formats like Egyptian 01066105963 or UK 07911123456 work — once the typed number is fully valid for the hinted country (from the environment chain or defaultCountry), the picker reveals.
  • Multi-country dial codes (+1 NANP, +7 RU/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 415 resolves to Switzerland (+41 5…), not the US area code. For region-locked apps, pass default-country instead.

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 #detecting slot. 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), the aria-invalid attribute, and the error message all stay neutral until the debounce settles. The raw validationState is still exposed via the template ref for consumers that want eager state.
  • Picker reveals after a no-match attempt — driven by an internal detectionAttempted flag. The picker won't disappear again until the input is cleared.
  • Programmatic v-model:phone changes bypass the gate — a parent setting phone is 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.

statejson

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.

ts
import { normalizeDigits } from '@alikhalilll/a-tel-input';

normalizeDigits('٠١٠٦٦'); // → '01066'

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.

statejson

Pass a locale and a messages bag to fully localise the component:

vue
<template>
  <div dir="rtl">
    <ATelInput
      v-model:phone="phone"
      v-model:country="country"
      locale="ar"
      default-country="20"
      show-validation
      :messages="{
        searchPlaceholder: 'ابحث عن دولة أو +رمز…',
        emptyText: 'لا توجد دول.',
        loadingText: 'جارٍ تحميل الدول…',
        suggestedLabel: 'مقترحة',
        allCountriesLabel: 'كل الدول',
        phoneInputLabel: 'رقم الهاتف',
        selectCountryLabel: 'اختر دولة',
        errorMessages: {
          too_short: 'الرقم قصير جدًا',
          invalid_phone: 'الرقم غير صحيح',
        },
      }"
    />
  </div>
</template>
  • locale localises country names via Intl.DisplayNames (search matches both the localised and English spelling) and renders the format-hint numerals in that locale.
  • messages bundles 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 matching messages key.

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.

vue
<ATelInput
  v-model:phone="phone"
  v-model:country="country"
  :allowed-dial-codes="['20', '971', '966']"
  default-country="20"
/>

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.

theme.csscss

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:

css
/* 1. Global override — every instance */
:root {
  --ak-ui-ring: 215 100% 70%;
  --ak-ui-accent: 215 50% 25%;
}

/* 2. Scoped class — per section / tenant */
.tenant-acme {
  --ak-ui-ring: 155 70% 50%;
}

/* 3. Inline — rapid prototyping (write straight on a wrapper :style) */

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.

generated.csscss

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
css
.tenant-acme {
  --ak-ui-popover: 155 50% 8%;
  --ak-ui-accent: 152 60% 30%;
  --ak-ui-ring: 152 70% 50%;
}
.tenant-nova {
  --ak-ui-popover: 270 40% 8%;
  --ak-ui-accent: 270 50% 30%;
  --ak-ui-ring: 270 90% 65%;
}

Radius variants

--ak-ui-radius is consumed directly (no Tailwind token):

css
.compact {
  --ak-ui-radius: 0.25rem;
}
.pill {
  --ak-ui-radius: 999px;
}

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

SlotScopeReplaces
prefixContent 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-iconJust the leading search icon.
loadingPicker loading state.
empty{ query }Empty / no-results state.
detectingSpinner 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-iconGreen 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

PropTypeReplaces
flagUrl(iso2, width) => stringDefault flagcdn.com URL builder.
countriesCountryOption[]Internal REST Countries fetch (curated or offline lists).
searcher(query, country) => booleanDefault substring match. Implement fuzzy / starts-with / etc.
detector(opts) => Promise<string | null>The environment chain. Return null to fall through.
errorMessagesPartial<Record<PhoneValidationReason, string>>Error labels (for i18n).
kbdOpen / kbdClosestring | nullThe ⌘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.

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.

Composed from primitives
Country
National number
E.164+?

Every primitive, composable, and helper is re-exported — fork-free composition:

vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ACountrySelect, usePhoneValidation } from '@alikhalilll/a-tel-input';

const country = ref('EG');
const phone = ref('');

const { validate } = usePhoneValidation();
const result = computed(() =>
  validate({ country: country.value ? { iso2: country.value } : null, phone: phone.value || '' })
);
</script>

<template>
  <div class="space-y-2">
    <ACountrySelect v-model:selected="country" size="md" />
    <input v-model="phone" type="tel" inputmode="numeric" placeholder="National number" />
    <p class="font-mono text-xs">{{ result.full_phone || '+?' }}</p>
  </div>
</template>