Phone Input
An international phone number input with country code selection, format-as-you-type, and E.164 validation.
Default
With onChange
onChange emits an E.164 string only when the number is complete and valid. Anything
else emits null — type a few digits below and watch the emitted value stay null
until the number is whole.
onChange(null, false)
Validation on blur
The library's error callback does not fire while a number is still partial, so
onBlur reports the validation error directly. A visibly-present partial that saves
as nothing is silent data loss — surface it.
No error
Read-only
readOnly alone does not close off the country dropdown — the selected country stays a
<button> and opens on click. Pair it with countrySelectorMode="OFF" for a true
read-only display.
Sizes
Disabled
Installation
npx visor add phone-inputThis copies three files into your project:
components/ui/phone-input/phone-input.tsx— the componentcomponents/ui/phone-input/phone-input.css— theme bindings for intl-tel-inputcomponents/ui/phone-input/phone-input.module.css— wrapper styles
It also installs intl-tel-input and @intl-tel-input/react as dependencies.
Important: Import the global CSS file in your app layout or entry point:
import '@/components/ui/phone-input/phone-input.css';Usage
import { PhoneInput } from '@/components/ui/phone-input/phone-input';
function MyForm() {
const [phone, setPhone] = useState<string | null>(null);
const [isValid, setIsValid] = useState(false);
return (
<PhoneInput
name="phone"
value={phone}
onChange={(e164, valid) => {
setPhone(e164);
setIsValid(valid);
}}
/>
);
}phone is either a valid E.164 string or null — it is never a partial, so it is safe
to persist directly.
API Reference
PhoneInputProps
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | HTML id attribute for the input element. |
name | string | — | HTML name attribute for the input element. |
value | string | null | — | Phone number in E.164 (e.g. "+14155551234"). Displayed formatted for the detected country. |
placeholder | string | — | Placeholder text for the input. |
required | boolean | false | Whether the field is required. |
disabled | boolean | false | Whether the field is disabled. |
readOnly | boolean | false | Whether the field is read-only. Pair with countrySelectorMode="OFF" to also close off the country dropdown. |
countrySelectorMode | "OFF" | "DROPDOWN" | "FULLSCREEN" | "AUTO" | "AUTO" | Country selector behaviour. "OFF" renders the selected country as a non-interactive element. |
initialCountry | Iso2 | "us" | ISO 3166-1 alpha-2 country selected on mount. |
size | "sm" | "md" | "lg" | "md" | Size variant — matches Input component sizes. |
className | string | — | Additional CSS classes applied to the wrapper. |
portal | { container: HTMLElement; scopeClassName: string } | — | Portal the country selector out of the input's DOM position. All-or-nothing — the theme-scoping class is not separable from the container, so a portaled selector can never escape its theme. Omit for the default inline selector. |
onChange | (e164: string | null, isValid: boolean) => void | — | Called with the E.164 number and its validity. e164 is null for anything that is not a complete, valid number — a partial is never emitted. |
onBlur | (error: ValidationError | null) => void | — | Called on blur with the validation error for a non-empty, invalid number (otherwise null). |
Features
- Country code selection — searchable dropdown with all countries and dial codes
- E.164 safe —
onChangeemits a valid E.164 string ornull, never a partial number - Format-as-you-type — applies per-country formatting rules as users type; the display keeps the user's partial typing even while the emitted value is
null - Paste detection — pasting a foreign number switches the selected country automatically
- Validation — powered by libphonenumber, with the error surfaced on blur
- SSR-safe — renders a plain
<input type="tel">on the server and hydrates in place
Theming
The country dropdown is themed by binding the library's own CSS custom properties to Visor tokens, so every theme is correct with no consumer-side CSS:
| Library variable | Visor token |
|---|---|
--iti-country-selector-bg | --surface-popover |
--iti-border-color | --border-default |
--iti-icon-color | --text-secondary |
--iti-hover-color | --surface-interactive-hover |
--iti-strict-reject-flash-color | --surface-error-subtle |
Each binding falls back to the library's own default, so a theme that does not define one of these tokens renders exactly as the unstyled library does.
Portaling the dropdown
The country selector is inline by default, which keeps it inside the consumer's theme
scope. If you need it portaled — to escape an overflow: hidden ancestor, say — pass the
portal prop. It is all-or-nothing on purpose: the container cannot be set without the
theme-scoping class that travels with it.
<PhoneInput
name="phone"
portal={{ container: document.body, scopeClassName: 'theme-space' }}
/>Accessibility
- Renders a native
<input>withtype="tel"andautocomplete="tel"for appropriate mobile keyboard and autofill - Always pair with a
<Label>usinghtmlFor/id, or providearia-labeldirectly - The country dropdown supports keyboard navigation and search
- Focus and caret survive parent re-renders — the wrapper skips writing back to the input while it is focused