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.
What you build
Section titled “What you build”- 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.
Step 1: Install
Section titled “Step 1: Install”pnpm add @telixon/web-sdk @telixon/core@telixon/web-sdk depends on @telixon/core; install both so your bundler resolves the engine.
Step 2: The markup
Section titled “Step 2: The markup”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.
<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>Step 3: Load the engine
Section titled “Step 3: Load the engine”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.
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.
Step 4: Create the phone input
Section titled “Step 4: Create the phone input”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.
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:
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, ornullwhen the region is unresolved.state.validationError: one structured error, ornullwhen the number is acceptable.
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.
Step 6: Build the region picker
Section titled “Step 6: Build the region picker”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.
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.
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.
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.
Step 7: Sync the picker and the field
Section titled “Step 7: Sync the picker and the field”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.
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:
// 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:
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:
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.
Step 8: Clean up
Section titled “Step 8: Clean up”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.
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.
Next steps
Section titled “Next steps”- Validation feedback tunes when the error shows while typing.
- Region picker builds the popover, keyboard model, and localized names.
- Input and picker sync extends the two-way binding with filters.
- Filters restricts both the field and the picker to a set of regions or number types.
- Placeholders covers how
state.placeholderis resolved and shaped. - Display config covers
callingCodeInInputand the threeplusPrefixmodes. - The web-sdk reference lists every option and method on
PhoneInputandRegionList.