# Phone Input (/docs/inputs/phone-input)



<ComponentShowcase name="phone-input" />

## Installation [#installation]

```bash
npx shadcn@latest add https://ui.zyeon.ai/r/phone-input.json
```

## Prompt [#prompt]

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

```text
Build a React + TypeScript + Tailwind "PhoneInput" component (lucide-react
Check + ChevronDown). No Radix, no popover library — the dropdown is drawn and
driven by the component itself.

Contract
- Export a forwardRef component whose ref points at the number <input>; props
  extend InputHTMLAttributes minus value/onChange/type.
- Controlled: value = { country: string; national: string } and
  onChange(next, e164) — the second argument is the composed
  "+<dialCode><digits>" string, or "" when there are no digits yet.
- countries?: { code, name, dialCode, flag }[] — ship a small common set as an
  exported constant and let consumers replace it wholesale (a full ISO list, a
  regional subset, a reordered one). An empty array is legal and must not throw:
  with nothing to pick, the trigger and its divider are not rendered at all, the
  control degrades to a plain national-number field and the emitted E.164 stays
  "" (a "+" with no dial code would be a lie).
- defaultCountry (default "US") is the fallback used when value.country
  matches nothing in countries — e.g. an empty initial value. The component
  always emits the country it actually rendered, so the parent converges.
- invalid, disabled, placeholder, className.

Behavior
- Left side: a real <button type="button"> with aria-haspopup="listbox",
  aria-expanded and aria-controls, showing the flag emoji plus "+<dialCode>"
  and a chevron that rotates when open (motion-reduce:transition-none).
- Opening (click or ArrowDown on the trigger) resets the filter, points the
  active index at the currently selected country, and focuses a filter input
  inside the popup so typing narrows immediately.
- Filter matches country name, dial code (with or without a leading "+") and
  ISO code, case-insensitive; an empty result renders a muted "no match" row
  instead of an empty box.
- Keyboard inside the popup: ArrowDown / ArrowUp move the active option
  (clamped, no wrap), Enter selects it, Escape closes and returns focus to the
  trigger. Pointer move over a row makes it active, so mouse and keyboard
  share one highlight. With zero matches the clamp holds the active index at 0
  instead of stepping to -1 — an index nothing maps to would blank
  aria-activedescendant and mute Enter until the query changed.
- The list is role="listbox" with role="option" + aria-selected rows; the
  filter input is role="combobox" with aria-controls + aria-activedescendant
  pointing at the active row's id.
- The list is max-h-60 overflow-auto and the active option is kept in view
  with scrollIntoView({ block: "nearest" }) whenever the active index moves.
- Click-outside closes: a document pointerdown listener that only exists while
  open and is removed on close and on unmount.
- Focus-out closes too: a focusout on the wrapper whose relatedTarget sits
  outside it resets `open`. Without it, Tabbing past the filter input (the rows
  are not focusable) walks straight out of the component and leaves an orphan
  listbox on screen with the trigger still aria-expanded="true". A null
  relatedTarget is deliberately ignored — that is a press on a non-focusable
  row, which the click-outside listener already owns.
- Selecting a country keeps value.national untouched (only the dial code
  changes), closes the popup and focuses the number field so typing continues.
- The number input accepts digits, spaces and hyphens only — anything else is
  stripped before it reaches the value, and E.164 is composed from digits only.
- Deliberate non-goal: no trunk-prefix rewriting and no per-country length
  validation. This is a composition shell; pair it with libphonenumber-js when
  you need real parsing/validation.

Rendering & styling
- One field-shaped container: h-9 rounded-md border border-input with
  focus-within:border-ring + focus-within:ring-ring/50, so trigger and input
  read as a single control; a 1px bg-border divider separates them. invalid
  swaps to border-destructive + ring-destructive/30; disabled dims to
  opacity-50 with cursor-not-allowed.
- Popup: absolute, z-50, rounded-md border border-border bg-popover
  text-popover-foreground shadow-md; the active row is bg-accent
  text-accent-foreground; dial codes are text-muted-foreground tabular-nums.
- Flags are aria-hidden decoration — the accessible name lives on the trigger
  ("Country: Germany (+49)"), so a missing emoji font never costs meaning.
- Semantic tokens only, merge the consumer className onto the relative
  wrapper with cn().

Customization levers
- Country data: replace `countries` with your own list to add every ISO
  country, restrict to the markets you ship in, or reorder by traffic — the
  component only reads code / name / dialCode / flag.
- Flag rendering: swap the emoji span for an SVG sprite or the ISO code when
  you need consistent glyphs on Windows.
- Trigger density: show only the flag (drop the dial code) for compact forms,
  or add the country name for wide desktop layouts.
- Popup placement: it opens downward at a fixed width — flip it to
  bottom-full when the field sits near the bottom of the viewport, or hand
  positioning to a popover library if your app already ships one.
- National formatting: run value.national through a formatter (AsYouType from
  libphonenumber-js) inside onChange to get live grouping; the E.164 output
  path is unaffected because it strips non-digits anyway.
- Validation: drive `invalid` from your schema (e.g. isValidPhoneNumber(e164))
  and render the message outside the component.
```

## Concepts [#concepts]

<Mermaid
  chart="`flowchart TD
A[&#x22;trigger button<br/>flag + dial code&#x22;] -->|&#x22;click / ArrowDown&#x22;| B[&#x22;open: reset filter,<br/>active = selected&#x22;]
B --> C[&#x22;filter input focused&#x22;]
C -->|&#x22;type&#x22;| D[&#x22;match name / dial code / ISO&#x22;]
C -->|&#x22;ArrowUp / ArrowDown&#x22;| E[&#x22;move active option&#x22;]
E --> F[&#x22;scrollIntoView block nearest&#x22;]
C -->|&#x22;Enter&#x22;| G[&#x22;select country&#x22;]
C -->|&#x22;Escape&#x22;| H[&#x22;close, focus back to trigger&#x22;]
I[&#x22;click outside<br/>(document pointerdown)&#x22;] --> H
M[&#x22;Tab out<br/>(focusout leaves the wrapper)&#x22;] --> N[&#x22;close, focus goes where Tab sent it&#x22;]
G --> J[&#x22;national text kept as-is&#x22;]
J --> K[&#x22;onChange(value, e164)&#x22;]
L[&#x22;number input<br/>digits, spaces, hyphens&#x22;] --> K`"
/>

* **One field, two controls** — the dial-code trigger and the number input live inside a single bordered box with `focus-within` styling, so a compound widget still reads (and validates) as one form field.
* **Self-drawn listbox** — the popup is a plain `role="listbox"` with `aria-activedescendant` on the filter input: focus never leaves the search box, which is what makes type-then-arrow-then-Enter feel continuous.
* **Active vs selected** — the highlighted row (keyboard/pointer position) and the chosen country (`aria-selected` + check mark) are separate ideas; conflating them is why hand-rolled dropdowns lose your place while filtering.
* **Country switch preserves the number** — changing the dial code never rewrites `national`, because users pick the country after typing at least as often as before.
* **E.164 as the emitted truth** — the display keeps the user's spacing and hyphens; the second `onChange` argument is the digits-only `+<dial><number>` string your API actually wants.
* **No orphan popup** — the popup is dismissed by three separate exits (Escape, outside pointerdown, and focus leaving the wrapper); the last one matters because the rows are not focusable, so Tab would otherwise walk out of the component and leave a highlighted listbox behind an `aria-expanded="true"` trigger.
* **An empty country list degrades, it does not throw** — with no countries the trigger disappears instead of dereferencing a country that is not there, and the E.164 output stays empty rather than emitting a dial-code-less `+`.
* **Listener lifecycle** — the click-outside `pointerdown` listener is created when the popup opens and removed when it closes or the component unmounts, so no stray global handlers survive a route change.
