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
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/phone-input.jsonPrompt
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-withinstyling, so a compound widget still reads (and validates) as one form field. - Self-drawn listbox — the popup is a plain
role="listbox"witharia-activedescendanton 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
onChangeargument 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
pointerdownlistener is created when the popup opens and removed when it closes or the component unmounts, so no stray global handlers survive a route change.
Currency Input
A money field that stays raw while you type and normalizes with Intl.NumberFormat on blur — grouping, fixed decimals, clamping, and a number | null value.
Inline Edit
Click-to-edit text that swaps a button for an input in place — pending state on async saves, rollback with the failure reason, and no width jump.