@input-kit/phone
v0.6.0
Published
Headless phone input with full world country codes, libphonenumber formatting, and TypeScript
Maintainers
Readme
@input-kit/phone
Headless React phone input with a complete world country-code dataset, searchable country selection, and libphonenumber-js powered formatting and validation.
Source, issues, and contributions: github.com/harshit-d3v/input-kit-phone. If this package saves you time, a ⭐ there helps others find it.
Latest update
0.6.0: the country selector is now a real ARIA combobox. The search field drives aria-activedescendant while keeping focus, the active option is the selected one, and a live region announces how many countries matched. Headless consumers get countrySearchProps, activeCountry, moveHighlight and countryResultsText.
0.5.0: lighter install. Dropped the world-countries runtime dependency (~600 kB unpacked); the names and dial codes it provided are now generated into the package (about 26 kB). The country list is byte-identical, nothing else changes.
0.4.2: documents and locks the built-in format as you type. Live national formatting on every keystroke was already there (formatOnType, on by default), but it was barely documented and had no regression tests. Added a README section and seven tests covering national, international, per-country, delete and paste formatting, plus proof that the stored and submitted value stays clean. No behavior change.
0.4.0: correctness fixes, please upgrade. E.164 was built by concatenating the dial code onto whatever was typed, so the national trunk prefix people actually type survived into the international form: UK 07400123456 became +4407400123456, and India, Germany, France and Australia were wrong the same way. Around 106 countries were affected, validation still reported these as valid, and North American numbers never were, which is why it went unnoticed. Now resolved through libphonenumber metadata, so countries that genuinely keep a leading zero (Italy) still do. Also fixes: an inline onValidationChange causing "Maximum update depth exceeded"; input past the country maximum being discarded instead of truncated, which silently blanked an empty field on paste; a country selection that reverted on international numbers; the country button not announcing the selected country to screen readers; focus dropping to the top of the page when the dropdown closed; and types failing to resolve for CommonJS TypeScript consumers under node16/nodenext.
0.3.0: auto-detect respects manual country selection, metadata-based length validation (no more false too_long in variable-length countries), input capped at the country's maximum length, isPhoneTooLong(), SSR-safe caret handling, dropdown Home/End + search→list keyboard navigation, unminified published output.
0.2.2: npm README cleanup: release notes stay inline; removed pointers to repo-only markdown files.
0.2.0: structured validation (ValidationReason, validatePhoneNumber, onValidationChange), parsePhoneValue, getCountryOptions(), improved PhoneInput a11y (click-outside, listbox ARIA) and RTL tests.
Features
- 245 supported calling regions derived from
libphonenumber-jsmetadata, with names and dial codes generated into the package so there is no heavy country-data dependency to install - Headless hook via
usePhoneInput()plus an optional unstyled referencePhoneInputcomponent (class names only, no bundled CSS) - Searchable country selector with country name, ISO code, and dial-code matching
- Accessible country selector built on the WAI-ARIA combobox pattern, with
aria-activedescendantnavigation and a live region announcing the filtered count - Format as you type in the selected country's format, on every keystroke, powered by
libphonenumber-js - Real validation powered by
libphonenumber-js - International detection for pasted or typed
+/00numbers - TypeScript-first exports for countries, helpers, hook return values, and component refs
Installation
npm install @input-kit/phoneQuick Start
Component
import { PhoneInput } from '@input-kit/phone';
import './phone-input.css'; // your own stylesheet, the package ships no CSS
function Example() {
return (
<PhoneInput
defaultCountry="US"
onChange={(phone, country) => {
console.log(phone, country?.code);
}}
/>
);
}Hook
import { usePhoneInput } from '@input-kit/phone';
function Example() {
const {
inputProps,
country,
countryButtonProps,
filteredCountries,
selectCountry,
isOpen,
isValid,
} = usePhoneInput({
defaultCountry: 'US',
onChange: (phone, nextCountry) => console.log(phone, nextCountry?.dialCode),
});
return (
<div>
<button {...countryButtonProps}>
{country?.flag} {country?.dialCode}
</button>
{isOpen && (
<div>
{filteredCountries.map((candidate) => (
<button key={candidate.code} onClick={() => selectCountry(candidate)}>
{candidate.flag} {candidate.name} {candidate.dialCode}
</button>
))}
</div>
)}
<input {...inputProps} />
{!isValid && <span>Invalid phone number</span>}
</div>
);
}Accessibility
The country selector follows the WAI-ARIA APG combobox with listbox popup pattern.
When the dropdown is searchable, the search field is the combobox: it carries role="combobox", aria-expanded, aria-controls pointing at the listbox, aria-autocomplete="list", and aria-activedescendant pointing at the active option. Focus stays in the search field while the arrow keys move the active option, so you can keep typing to refine the list. aria-selected marks the active option, as a single-select listbox should. Opening the list makes the country you already picked active, so a screen reader reads the current value first.
Keys: ArrowDown and ArrowUp move the active option, Home and End jump to the ends, Enter selects it, Escape closes the list and returns focus to the trigger button. A polite live region reports how many countries the query matched, or that none did.
With searchable={false} there is no combobox, so the options keep roving focus and the same keys apply to the list itself.
The trigger button is named with the selected country and its dial code, not just the action, because a constant aria-label would otherwise hide the flag and dial code from the accessibility tree.
Building your own UI on usePhoneInput() gets the same wiring: spread countrySearchProps on your search field, dropdownProps on the list, and getCountryOptionProps(country, index) on each option. countryResultsText is the localized live-region string. All the announcement strings go through the labels option.
Format as you type
inputProps.value reformats on every keystroke in the selected country's national format, so the field reads (555) 123-4567 while the user types. Typing a + switches to international format (+1 555 123 4567). This is on by default; set formatOnType: false for raw digits.
The formatting is display only. onChange, phone, fullPhone and parsePhoneValue still give you the clean value, so what you store and submit is never the formatted string.
const { inputProps, fullPhone } = usePhoneInput({
defaultCountry: 'US',
includeDialCode: true,
onChange: (phone) => console.log(phone), // +15551234567, not "(555) 123-4567"
});
// user types 5551234567
// inputProps.value -> "(555) 123-4567"
// fullPhone -> "+15551234567"Phone values
| Field | Meaning |
| --- | --- |
| phone | National digits stored by the hook (default) |
| fullPhone | National number plus dial code when includeDialCode is true |
| onChange(phone, country) | Same contract as phone / includeDialCode |
| E.164 for APIs | parsePhoneValue(phone, country).e164 when valid. Prefer this over raw concatenation |
Known behavior
Formatting, length checks, and validity follow libphonenumber-js (same family as react-phone-number-input). The package does not implement per-country rules outside that library.
Form integrations
Controlled value / onChange with usePhoneInput or PhoneInput. React Hook Form: wrap with Controller and pass field.value, field.onChange, and field.onBlur. Use onValidationChange to sync isValid / message with form errors. Submit-time checks: parsePhoneValue(phone, country) or validatePhoneNumber.
Styled example
A minimal Vite demo using only the hook lives in examples/react-styled/.
Migration from 0.1.x
Compatibility aliases remain exported. Prefer the newer names in new code:
| Deprecated | Replacement |
| --- | --- |
| setValue | setPhone |
| value (hook) | phone |
| allowedCountries | onlyCountries |
| excludedCountries | excludeCountries |
| autoDetectCountry | autoDetect |
| toggle / open / close | toggleDropdown / openDropdown / closeDropdown |
| countries (hook list) | filteredCountries |
| countrySelectorProps | countryButtonProps |
Validation is unified in 0.2.0: isValid and error come from the same validatePhoneNumber call. Use validationReason or onValidationChange for structured form messages.
Development
Requires Bun (see packageManager in package.json).
bun install
bun run test
bun run typecheck
bun run build
bun run lintManual browser check: test-demo/ (static HTML).
Contributing
Bug reports, feature requests, and pull requests are welcome, see CONTRIBUTING.md. In short: open an issue with a minimal reproduction (include the exact phone number and country for formatting/validation bugs), and for PRs run bun run test, bun run typecheck, and bun run lint before submitting.
Exports
Components and hooks
PhoneInputusePhoneInput(options)
Country data
countriesgetCountryByCode(code)getCountryByDialCode(dialCode)getCountriesByDialCode(dialCode)getCountryOptions({ locale?, preferredCountries?, excludeCountries?, onlyCountries? })detectCountryFromPhone(phone)
Utilities
cleanPhone,formatPhone,unformatPhone,validatePhone,validatePhoneLengthvalidatePhoneNumber→{ isValid, reason, message, error }parsePhoneValue→{ country, nationalNumber, e164, isValid }addDialCode,removeDialCode,filterCountries,getPlaceholder
Compatibility aliases: stripNonDigits, detectCountry, formatPhoneNumber, parseToE164, getNationalNumber, isPhoneNumberComplete, formatAsYouType, normalizePhoneNumber, phoneNumbersEqual, getCountryDisplayLabel, limitInputLength.
usePhoneInput(options)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| defaultCountry | string | 'US' | Default selected country |
| preferredCountries | string[] | - | Countries shown first in search results |
| excludeCountries | string[] | - | Countries to exclude |
| onlyCountries | string[] | - | Restrict selection to these countries |
| autoDetect | boolean | true | Detect country from international numbers |
| formatOnType | boolean | true | Apply live formatting |
| includeDialCode | boolean | false | Return values with dial code included |
| required | boolean | false | Empty value is invalid |
| validator | (phone, country) => boolean | - | Custom validation override |
| onValidationChange | (state) => void | - | Fires when validation result changes |
Important returned fields: phone, fullPhone, country, isValid, validationReason, error, onValidationChange, filteredCountries, inputProps, countryButtonProps, dropdownProps, getCountryOptionId.
License
MIT © Input Kit
