Phone number

A dialling country and a national number in one field. The country and the number are separate values, because storing them joined is a decision you should make, not one the field makes for you.

(Optional)
This is a hint text to help users.
<PhoneInput
  size="md"
  label="Phone number"
  required
  optional
  info="We only use this to secure your account."
  defaultCountry="US"
  hint="This is a hint text to help users."
/>

Two values

onChange gives the number exactly as typed. onCountryChange gives the ISO code. Neither is formatted, normalised or validated. Yöte does not own validation, and phone numbers are the worst possible place to start.

contact-form.tsx
const [country, setCountry] = useState('GB')
const [number, setNumber] = useState('')

<PhoneInput
  label="Phone number"
  country={country}
  onCountryChange={setCountry}
  value={number}
  onChange={setNumber}
/>

Countries

The default list is eight common entries, enough to see the component work and not a data set. Pass your own for anything real.

<PhoneInput
  countries={[
    { code: 'FI', dial: '+358', name: 'Finland' },
    { code: 'SE', dial: '+46', name: 'Sweden' },
  ]}
  defaultCountry="FI"
/>

Flags

The eight defaults draw a real SVG flag, inline, with no request and no asset pipeline. The first version used the regional-indicator emoji, 🇬🇧 built from the letters G and B. It is free and needs nothing, and it is missing entirely on Windows: there it renders the two letters. A default that looks right on a Mac and broken on a PC is not a default.

A country the library does not draw still falls back to the emoji, so pass flag for anything outside the eight. It takes any node. The full 260 belong in your bundle rather than ours. The usual answer is flag-icons, which is where these eight came from, MIT, and pure CSS.

phone-field.tsx
import 'flag-icons/css/flag-icons.min.css'

<PhoneInput
  countries={[
    { code: 'FI', dial: '+358', name: 'Finland' },
    { code: 'EE', dial: '+372', name: 'Estonia', flag: <span className="fi fi-ee" /> },
  ]}
/>

The picker

The list is a popover rather than a native <select>, because the design is a search field, a flag and a two-column row and a select can render none of those. Everything the select gave away for nothing is rebuilt explicitly: combobox roles, arrows with wrap, Home and End, Enter and Escape, and the highlighted option tracked with aria-activedescendant so focus stays in the search field and typing never breaks.

It renders into document.body and positions itself against the viewport, flipping above the field when there is no room below. That is not fussiness: a dropdown that stays inside the field is at the mercy of every ancestor, and one overflow: hidden on a card, a modal or a preview stage slices it in half. You do not control that and should not have to think about it.

Sizes

The left padding grows with the size while the right stays at 8px: the country group sits at the start and the number runs to the end.

sizeRadiusPadding left / rightNotes
sm8px12px / 8pxFigma 26:9409.
md12px14px / 8pxFigma 26:9410. The default.
lg12px16px / 8pxFigma 26:9411.

Props

PropTypeDefaultNotes
countriesPhoneCountry[]eight common onesThe selectable dialling countries.
countrystringSelected ISO code, controlled.
defaultCountrystring"US"Selected ISO code, uncontrolled.
onCountryChange(code: string) => voidFires when the country changes.
requiredbooleanfalseRenders the asterisk and sets aria-required.
optionalbooleanfalseRenders the muted "(Optional)" note.
infostringInfo marker beside the label, with this as its tooltip.

Shared

PropTypeDefaultNotes
valuestringControlled value.
defaultValuestringInitial value when uncontrolled.
onChange(value: string) => voidReceives the value, never the event.
labelReactNodeRendered as a real <label> wired by htmlFor.
hintReactNodeHelper text under the field.
errorReactNodeError text. Implies invalid unless invalid says otherwise.
invalidbooleanForces the error styling on or off.
errorKeystring | numberChange it to replay the error animation.
disabledbooleanfalseGreys the field out.
readOnlybooleanfalseReads as filled, stays focusable.
size'sm' | 'md' | 'lg''md'Size scale, where the component defines one.
classNamesRecord<Part, string>Per-part class names.