About this animation
Related guides
A phone field with a searchable country picker and cursor-aware formatting, with configurable countries, labels, palette, and fonts.
How it works
- Keep ISO country identity separate from national digits. Countries that share a dial code, such as US and CA, remain distinct.
- Normalize decimal digits without truncating. A consumer formatter receives digit offsets and returns formatted text and text offsets. A formatter that throws or changes digits falls back to unformatted digits.
- Map both selection endpoints by their preceding digit counts. Deleting only a separator removes the adjacent digit in the deletion direction. The same input stays mounted through country changes and validation.
- Validate through a synchronous consumer adapter. Without one, changes emit
e164: null; this component does not implement worldwide phone validation. - Drive the contained country sheet and scrim from one interruptible UI-thread spring. Search is immediate and does not change the phone value until a country is selected.
- Coordinate helper text and error border over 160 ms. Host errors persist until the host removes them. The demo corrects a middle digit before opening search, using its own animation timings rather than measured video timing.
Install
Copy the component files listed in piece.json into your project, then install:
npx expo install react-native-reanimated react-native-workletsUse Reanimated's Expo SDK setup and New Architecture. No phone library, network country service, images, safe area provider, or gesture wrapper is required by the component. The optional catalog demo additionally uses Expo fonts, safe area context, and Skia; these are not component dependencies.
Usage
import { PhoneCountryInput } from './phone-country-input';
const countries = [
{ iso2: 'US', name: 'United States', dialCode: '+1' },
{ iso2: 'CA', name: 'Canada', dialCode: '+1' },
{ iso2: 'GB', name: 'United Kingdom', dialCode: '+44' },
];
export function PhoneForm() {
return (
<PhoneCountryInput
countries={countries}
defaultValue={{ countryIso2: 'US', nationalNumber: '' }}
labels={{ title: 'Your phone number', country: 'Country or region' }}
/>
);
}Give the component a bounded parent with flex: 1, or override its root size with style. The host owns safe areas. The component owns its form scroll and country list scroll; the sheet stays inside that host instead of mounting a global modal.
Controlled integration with personality knobs:
const [value, setValue] = useState({
countryIso2: 'US',
nationalNumber: '',
});
const control = useRef<PhoneCountryInputHandle>(null);
<PhoneCountryInput
ref={control}
countries={countries}
value={value}
onChange={setValue}
validateNumber={phoneAdapter.validate}
parseInternational={phoneAdapter.parse}
colorScheme="dark"
palette={{ accent: '#76D6C4', focus: '#76D6C4' }}
motion={{ durationScale: 0.85, glowOpacity: 0.12 }}
helperText="Choose your country, then enter your number."
validText="Number confirmed."
/>;Import useRef, useState, and PhoneCountryInputHandle for the second example. phoneAdapter belongs to your application; its validator must check numbering rules rather than length alone. It should return { valid, error?, e164? }, with a complete E.164 string only on success.
Formatting example (does not impose a length limit):
import { displaySelection } from './phone-country-input/model';
import type { PhoneCountryInputProps } from './phone-country-input';
const formatNumber: NonNullable<PhoneCountryInputProps['formatNumber']> = (
digits,
country,
{ selection },
) => {
const text = digits.length > 3
? `${digits.slice(0, 3)} ${digits.slice(3)}`
: digits;
return { text, selection: displaySelection(text, selection) };
};Formatter input selection refers to offsets in the raw digit string. Output selection, public selection, and change metadata refer to offsets in formatted text. A custom formatter must preserve all digits, including long and incomplete input. onChange fires once per committed edit, country selection, setValue, or reset; selection-only changes call onSelectionChange without a value callback.
International paste is delegated to parseInternational(text, countries). A successful parse supplies both ISO identity and national digits. Without it, or if it throws/returns null, all pasted digits remain under the current country. A shared +1 never chooses between US and CA. Browser paste is detected explicitly; native insertion of multiple characters is classified as paste when the inserted text grows by more than one character.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
countries | readonly PhoneCountry[] | Required | ISO, name, dial code, optional search terms and disabled state. |
value / defaultValue | PhoneValue | First enabled country, empty digits | Controlled value / uncontrolled initial and reset value. |
onChange | (value, meta) => void | — | Formatted text, E.164 or null, selection, and reason: edit/country/paste/reset. |
formatNumber | (digits, country, {selection}) => PhoneFormat | Digits only | Preserves digits and maps selection. |
validateNumber | (digits, country) => PhoneValidation | No validation | Synchronous consumer validation. Exceptions fail validation. |
parseInternational | (text, countries) => PhoneValue | null | Preserve all digits | Consumer policy for pasted international numbers. |
selection / onSelectionChange | PhoneSelection / callback | Internal selection | Controlled selection request/response. |
open / onOpenChange | boolean / callback | Internal, closed | Controlled country sheet. |
query / onQueryChange | string / callback | Internal, empty | Independent local search. |
renderFlag | (country) => ReactNode | No flag | Consumer artwork; ISO text is always shown. |
disabled | boolean | false | Blocks interaction and handle actions except reset. |
readOnly | boolean | false | Prevents value changes and country selection. |
error | string | — | Persistent external error, takes priority over adapter errors. |
onBlur | () => void | — | Native number input blur. |
onSubmit | (value, meta) => void | — | Continue/keyboard submit after consumer validation. |
labels | Partial<PhoneCountryInputLabels> | Neutral English | Title, phone, country, search, close, continue, empty, invalid, selectedCountry(country), resultCount(count). |
helperText / validText | string | Empty | Reserved helper area and confirmed-number message. |
colorScheme | light | dark | auto | auto | Paper, Night, or system. |
palette | Partial<Palette> | Theme tokens | background, surface, elevated, text, muted, border, accent, onAccent, accentSoft, glow, success, error, scrim, track, focus. |
fontFamily / titleFontFamily / monoFontFamily | string | System fonts | Body, title/buttons, and number. |
motion | {durationScale?, glowOpacity?} | 1, 0.12 | Clamped to 0.5–2 and 0–0.3; nonfinite values use defaults. |
style | StyleProp<ViewStyle> | — | Root override, applied last. |
testID | string | phone-country-input | Root; stable suffixes -country, -number, -help, -continue, -search, -close, -scrim, -option-<ISO>. |
accessibilityLabel | string | — | Optional root description. |
The handle exports focus(), blur(), openCountries(), closeCountries(), setValue(value), and reset(). Controlled props always win: handle methods request changes through callbacks instead of overriding controlled state. setValue emits reason edit; reset emits reset and requests the initial value, empty query, and closed sheet. Reset never clears an external error.
Invalid ISO records, empty names, malformed dial codes, and duplicate ISO IDs are ignored; the first valid occurrence wins. Unknown value IDs fall back to the first enabled country. An empty country list renders a disabled selector and Continue action. Numbers are never silently shortened. Nonfinite/negative selection endpoints are clamped. Supplying a disabled country as the current value preserves its identity, but it cannot be chosen from the list.
Compatibility
Designed for Expo SDK 58, React 19, React Native 0.88.0, and Reanimated 4.7.1 with New Architecture. Supports iOS, Android, and web, including Expo Go with the matching SDK. No native directories or additional native dependency are required.
Compilation, isolated bundling, browser layout, and browser editing checks are separate from native touch, keyboard/IME behavior, screen reader operation, and hardware FPS measurements. No frame-rate guarantee is implied. Native text services and web composition use separate input adapters; a target IME must be exercised on its actual OS before shipping a localized phone flow.
Accessibility
Controls have at least 48-point targets, explicit labels, country identity and prefix, radio selection state, and a textual error. Number text stays LTR inside RTL layouts. Text remains scalable and long country names wrap. The form scrolls when space is constrained; the country list remains virtualized below a fixed header and search.
Reduced motion replaces sheet movement with a 200 ms fade and removes press scale. Changes to the native reduced-motion preference are observed. Browser keyboard controls include Tab/Shift+Tab within the open sheet, arrow navigation through mounted country rows, Enter to choose, and Escape to close. Native Back closes the sheet. Closing restores focus to the country control. Error announcements occur on message changes, never for inserted separators or animation frames. Every announcement and visible string is configurable.
The demo provides eight sample countries, one disabled entry (Japan), and two explicit fictional number fixtures: US/CA 2025550148 and GB 7700900148. Its validator is a fixture matcher, not production numbering data. System emoji flags are demo content with visible ISO fallback. Raster assets: N/A. Demo cloud icon: Lucide (ISC), copied from the repository kit; demo identity: Nimbus. No reference artwork is distributed.
Why this one
This field keeps country identity, dial code, national digits, formatting, and cursor selection together without guessing global validation rules. Search is single selection, errors reserve their space, and country changes preserve the input instance and national number. The contained sheet also fits embedded forms where a global modal would escape the host.






