Skip to content

Build an international phone field

This tutorial builds a working international phone field from an empty page. By the end you have a single <input> that formats as the user types, resolves the region from the digits, renders the right validation message, shows a real example number as its placeholder, and pairs with a searchable region picker that stays in sync with the field.

Everything runs on two packages: @telixon/core (the engine, parsing, validation, and the headless input controllers) and @telixon/web-sdk (the DOM adapter that binds a controller to an <input>). The web-sdk ships no CSS and no images; the markup and styling below are yours to change.

  • An international field that keeps the calling code inside the value with an erasable +.
  • Live validation rendered from state.validationError.
  • The field’s own placeholder, a real example number for the resolved region.
  • A region picker built from createRegionList: a search box, flag emoji, localized names, and a few regions pinned to the top.
  • Two-way sync: picking a region drives the field; typing a number that resolves elsewhere moves the picker’s selection.
Terminal window
pnpm add @telixon/web-sdk @telixon/core

@telixon/web-sdk depends on @telixon/core; install both so your bundler resolves the engine.

One input for the number, one button that opens the picker, one popover holding the picker’s search box and list, and one element for the validation message. The data- and aria- hooks are wired up in later steps.

index.html
<div class="phone-field">
<button type="button" id="region-button" aria-haspopup="listbox" aria-expanded="false">
<span id="region-flag">🇺🇸</span>
<span id="region-name">United States</span>
</button>
<input id="phone" type="tel" autocomplete="tel" aria-describedby="phone-error" />
<div id="region-popover" hidden>
<input id="region-search" type="text" placeholder="Search regions" aria-label="Search regions" />
<ul id="region-list" role="listbox" aria-label="Region"></ul>
</div>
<p id="phone-error" role="alert"></p>
</div>

Every entry point that touches numbers needs the engine loaded first. ensureEngineReady() resolves once and is shared process-wide: call it wherever the field mounts, and concurrent callers reuse the same load.

phone-field.ts
import type { RegionCode } from '@telixon/core';
import { ensureEngineReady } from '@telixon/core';
import type { PhoneInputState, RegionListState, RegionOption } from '@telixon/web-sdk';
import { createPhoneInput, createRegionList, regionToFlagEmoji } from '@telixon/web-sdk';
await ensureEngineReady();

createPhoneInput and createRegionList both throw EngineNotReadyError if called before the engine resolves, so keep the rest of the setup after the await. The type imports (RegionCode from core; PhoneInputState, RegionListState, and RegionOption from web-sdk) are used in the steps that follow.

Grab the input element, then attach an international controller. callingCodeInInput: true keeps the calling code inside the value; plusPrefix: 'erasable' renders a leading + the user can delete and retype. defaultRegion decides which calling code the field starts on.

phone-field.ts
const input = document.querySelector<HTMLInputElement>('#phone');
if (input === null) throw new Error('#phone not found');
const phoneInput = createPhoneInput({
mode: 'international',
input,
defaultRegion: 'US',
display: { callingCodeInInput: true, plusPrefix: 'erasable' },
});

At construction the field already holds +1 and reports region US. That initial state is written to the DOM but not emitted to subscribers, so read it once with getState() to seed your own view:

phone-field.ts
let state = phoneInput.getState();
// state.value -> '+1 '
// state.region -> 'US'
// state.placeholder -> '+1 201-555-0123'
// state.validationError -> { kind: 'TOO_SHORT', minLength: 10 }

state.validationError is TOO_SHORT because +1 alone is a complete calling code but zero national digits. It clears the moment a full number is present.

Step 5: Render validation and the placeholder

Section titled “Step 5: Render validation and the placeholder”

Subscribe once. Every keystroke, deletion, paste, or undo emits a fresh PhoneInputState. The adapter has already written value and the caret to the input; your subscriber renders the rest. Two fields matter here:

  • state.placeholder: a real example number for the resolved region and this display config, or null when the region is unresolved.
  • state.validationError: one structured error, or null when the number is acceptable.
phone-field.ts
const errorElement = document.querySelector<HTMLParagraphElement>('#phone-error');
if (errorElement === null) throw new Error('#phone-error not found');
function messageFor(error: NonNullable<PhoneInputState['validationError']>): string {
switch (error.kind) {
case 'TOO_SHORT':
return `Too short: expected at least ${error.minLength} digits.`;
case 'TOO_LONG':
return `Too long: expected at most ${error.maxLength} digits.`;
case 'INVALID_CALLING_CODE':
return 'Start with a country calling code, for example +1 or +44.';
default:
return 'This phone number is not recognized.';
}
}
phoneInput.subscribe((nextState) => {
state = nextState;
input.placeholder = nextState.placeholder ?? '';
errorElement.textContent = nextState.validationError ? messageFor(nextState.validationError) : '';
});

messageFor handles the kinds a live international field produces most; the full discriminated union has eight kinds. Form errors lists every kind, its payload, and a verified message for each, and Validation feedback covers when to show the message versus when to stay quiet while typing.

Typing a full US number now walks the field through resolution and clears the error:

After the user types state.value state.region state.validationError
nothing +1 US { kind: 'TOO_SHORT', minLength: 10 }
415 +1 415- US { kind: 'TOO_SHORT', minLength: 10 }
4155552671 +1 415-555-2671 US null

At the last row phoneInput.getPhoneNumber().formatE164() returns +14155552671. getPhoneNumber() builds the same PhoneNumber interface parsePhoneNumber returns, so submit-time validation and E.164 formatting read from one place. Validate on submit covers that flow.

createRegionList is a second headless controller. It produces a filtered, sorted, searched, and localized list of every region as RegionOption entries. Each option carries what a picker row needs:

Field Example Notes
region 'US' ISO region code; the value you pass to phoneInput.setRegion.
callingCode '1' Numeric calling code as a string.
displayName 'United States' Localized name from the runtime’s Intl.DisplayNames.
data undefined Your own payload when you pass a dataFactory; unused otherwise.

Create the list with a locale for the names, and pin a few regions to the top with prioritize. The default sort is alphabetical by localized name.

phone-field.ts
const regionList = createRegionList({
locale: 'en',
prioritize: ['US', 'GB', 'DE'],
});

createRegionList fires no initial emit either. Read the first list with getState(), then render. Pair each row’s flag with regionToFlagEmoji, which maps a region code to its two Unicode regional indicator symbols (regionToFlagEmoji('US') is 🇺🇸). Platforms without flag-emoji support show the two letters instead.

phone-field.ts
const listElement = document.querySelector<HTMLUListElement>('#region-list');
if (listElement === null) throw new Error('#region-list not found');
function renderList(listState: RegionListState): void {
listElement.replaceChildren(
...listState.options.map((option: RegionOption) => {
const item = document.createElement('li');
item.role = 'option';
item.dataset.region = option.region;
item.textContent = `${regionToFlagEmoji(option.region)} ${option.displayName} +${option.callingCode}`;
return item;
}),
);
}
renderList(regionList.getState());
regionList.subscribe(renderList);

Wire the search box to regionList.search. The controller re-runs its pipeline and emits, so renderList redraws from the subscription. The default search matches localized name, region code, and calling code, and ignores diacritics.

phone-field.ts
const searchElement = document.querySelector<HTMLInputElement>('#region-search');
if (searchElement === null) throw new Error('#region-search not found');
searchElement.addEventListener('input', () => regionList.search(searchElement.value));

Region picker covers the popover markup, keyboard navigation, and localized names in depth.

Two directions to keep aligned.

Picking a region drives the field. On a click, switch the field’s region and seed the new calling code so an empty field shows the right prefix. phoneInput.setRegion re-resolves the current digits under the new region; phoneInput.setValue then writes the + and the calling code as the starting value. Each RegionOption already carries the callingCode, so no lookup is needed.

phone-field.ts
listElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const item = target.closest<HTMLLIElement>('li[data-region]');
if (item === null) return;
const region = item.dataset.region;
const callingCode = item.dataset.callingCode;
if (region === undefined || callingCode === undefined) return;
phoneInput.setRegion(region as RegionCode);
phoneInput.setValue('+' + callingCode);
closePopover();
});

Store the calling code on the row so the click handler has it without a second lookup:

phone-field.ts
// in renderList, alongside item.dataset.region:
item.dataset.callingCode = option.callingCode;

Picking a region seeds the field exactly:

Picked phoneInput.getState().value state.region
GB +44 GB
DE +49 DE
UA +380 UA

Typing a full national number after the seed completes normally: seeding GB and typing 7400123456 yields value +44 7400 123456, region GB, and formatE164() +447400123456.

Typing drives the picker. In international mode the region is resolved from the digits, so a user who types or pastes a number from another region moves state.region on their own. Reflect that back onto the button so the picker’s shown region always matches the field:

phone-field.ts
const flagElement = document.querySelector<HTMLElement>('#region-flag');
const nameElement = document.querySelector<HTMLElement>('#region-name');
if (flagElement === null || nameElement === null) throw new Error('region button parts not found');
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' });
function reflectRegion(region: PhoneInputState['region']): void {
if (region === null) return;
flagElement.textContent = regionToFlagEmoji(region);
nameElement.textContent = displayNames.of(region) ?? region;
}
reflectRegion(state.region);

Call reflectRegion(nextState.region) from inside the existing phoneInput.subscribe callback so the button follows every resolution. Add the popover open and close helpers your region-button and click handler reference:

phone-field.ts
const button = document.querySelector<HTMLButtonElement>('#region-button');
const popover = document.querySelector<HTMLDivElement>('#region-popover');
if (button === null || popover === null) throw new Error('popover parts not found');
function openPopover(): void {
popover.hidden = false;
button.setAttribute('aria-expanded', 'true');
searchElement.focus();
}
function closePopover(): void {
popover.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
button.addEventListener('click', () => (popover.hidden ? openPopover() : closePopover()));

The field and the picker now share one source of truth: state.region reflects onto the button, and the button writes back through setRegion. Input and picker sync covers the same wiring with filters and edge cases.

When the field leaves the page, detach both controllers. destroy() removes the adapter’s DOM listeners, clears subscribers, and releases the input for a future attach; the region list’s destroy() clears its subscribers.

phone-field.ts
function teardown(): void {
phoneInput.destroy();
regionList.destroy();
}

Inside a component framework, run teardown from the unmount hook. Frameworks shows the Angular, React, and Vue lifecycle wiring.