Inputs

Phone Input

A country dial-code picker fused to a number field — searchable self-drawn listbox, keyboard driven, emitting an E.164 string on every change.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils
"use client"

import * as React from "react"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"

export interface PhoneCountry {
  /** ISO 3166-1 alpha-2, used as the identity in `value.country`. */
  code: string
  name: string
  /** Digits only, no "+". */
  dialCode: string
  flag: string
}

Installation

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

Prompt

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

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.
- A change whose text starts with "+" (a pasted or autofilled international
  number such as "+44 20 7946 0958") switches the country to the longest
  matching dial code in `countries` and keeps only what follows the dial code
  as value.national. A shared code (+1 for US and CA) stays on the current
  country when it matches, else takes the first listed one. With no matching
  dial code the "+" is stripped like any other character. Without this the
  "+" would be dropped and the selected dial code prefixed, emitting a
  plausible-looking wrong E.164.
- 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

  • 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.

On This Page