VisorVisor
ComponentsForm

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-input

This copies three files into your project:

  • components/ui/phone-input/phone-input.tsx — the component
  • components/ui/phone-input/phone-input.css — theme bindings for intl-tel-input
  • components/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

PropTypeDefaultDescription
idstring—HTML id attribute for the input element.
namestring—HTML name attribute for the input element.
valuestring | null—Phone number in E.164 (e.g. "+14155551234"). Displayed formatted for the detected country.
placeholderstring—Placeholder text for the input.
requiredbooleanfalseWhether the field is required.
disabledbooleanfalseWhether the field is disabled.
readOnlybooleanfalseWhether 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.
initialCountryIso2"us"ISO 3166-1 alpha-2 country selected on mount.
size"sm" | "md" | "lg""md"Size variant — matches Input component sizes.
classNamestring—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 — onChange emits a valid E.164 string or null, 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 variableVisor 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> with type="tel" and autocomplete="tel" for appropriate mobile keyboard and autofill
  • Always pair with a <Label> using htmlFor/id, or provide aria-label directly
  • 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