Phone Number Input
A mobile phone field that regroups digits as you type, picks the country from a bottom sheet, and normalises pasted international numbers into E.164.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/phone-number-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PhoneNumberInput" component for mobile
(lucide-react: Check, ChevronDown, CircleAlert, Search, X). No Radix, no popover
library: the country picker is a bottom sheet this component draws itself and
portals with createPortal.
Contract
- forwardRef component; the ref points at the number <input>. Props extend
InputHTMLAttributes minus value / defaultValue / onChange / type; the rest
spread onto that input, className lands on the wrapper.
- Controlled and uncontrolled both: value / defaultValue are
{ country: string; national: string } where `national` is DIGITS ONLY — the
grouping on screen is derived, never stored. onChange(value, detail) with
detail = { e164, formatted, status, complete, country }.
- status: "empty" | "incomplete" | "complete" | "over" | "unknown". "unknown" is
the honest answer for a country that declares no length — never claim complete
from data you do not have.
- countries?: PhoneCountry[] where PhoneCountry =
{ code, name, dialCode (digits, no "+"), groups?: number[], length?: number,
flag?: string, trunkPrefix?: string }. Ship a small common set as an exported
constant; consumers replace it wholesale. `groups` drives both the formatting
and the placeholder mask; `length` defaults to the sum of groups.
An empty array is legal and must not throw: no trigger is rendered at all, the
control degrades to a national-only field, and e164 stays "" (a "+" with no
dial code would be a lie).
- defaultCountry (default "US") is the fallback when value.country matches
nothing in countries. The component always emits the country it actually
rendered, so a controlled parent converges on the next edit.
- variant: "inline" (one fused field row) | "stacked" (a full-width country row,
name spelled out, above the number) | "display" (no chrome: a dial-code chip
over a large centred number for a sign-in screen).
- label, hint, invalid, errorText, separator (default " "), showClear, disabled,
labels (i18n strings), formatProgress(typed, expected), formatOver(extra),
onCountryChange(country), container (portal target; see below).
Behavior — typing
- The field is a real <input type="tel" inputMode="tel" autoComplete=
"tel-national" enterKeyHint="done" autoCorrect="off" spellCheck={false}>, so a
phone raises the numeric keypad instead of QWERTY.
- Every edit is re-expressed in DIGIT SPACE: count the digits behind the caret,
rebuild the digit string, regroup it with the country's `groups`, then convert
that digit count back into a string index. This is the only coordinate system
that survives regrouping — it is why editing in the middle does not fling the
caret to the end.
- Write the grouped text back onto the DOM node synchronously inside the change
handler (node.value = formatted; setSelectionRange(pos, pos)) BEFORE calling
setState: React only assigns node.value when it differs, and that assignment is
exactly what would drop the caret at the end. Keep a pendingCaret ref and
re-apply it in a useLayoutEffect as a backstop for the commits where a
controlled parent does rewrite the value.
- Deleting a separator alone would regroup to the identical string and the field
would look stuck: when the digits did not change but the text got shorter, hop
the deletion onto the neighbouring digit — behind the caret for Backspace,
ahead of it when nativeEvent.inputType is deleteContentForward.
- Digits past the last declared group become one trailing chunk instead of being
dropped. The hard ceiling is E.164's 15 digits minus the dial code's length.
- Only BULK input is ever rewritten (inputType insertFromPaste / insertFromDrop /
insertReplacementText, or more than one digit arriving in one event). Three
shapes, in order: leading "+" or "00" -> match the LONGEST dial code prefix and
switch country; a redundant home dial code ("1 415 555 2671" while US is
selected) -> stripped only when what is left is exactly a national number; a
national trunk prefix ("07700 900123") -> dropped. A single keystroke is never
rewritten, because a field that edits what you just typed is unusable. After a
rewrite the caret goes to the end.
- The clear button empties the number and re-focuses the input — clearing must
not dismiss the software keyboard.
Behavior — the country sheet
- The trigger is a real <button type="button"> with aria-haspopup="dialog",
aria-expanded and an accessible name like "Country: Japan (+81)". The flag is
decorative (aria-hidden), the ISO code is its fallback glyph.
- The sheet is role="dialog" aria-modal, anchored to the bottom edge, rising from
translateY(100%) to 0 over ~280ms with cubic-bezier(0.32,0.72,0,1). Mount is
separate from open so the exit animation can finish; the entrance needs a
double requestAnimationFrame or the browser coalesces both styles and it
teleports. Under prefers-reduced-motion the transitions are off and the sheet
unmounts immediately — it still opens, closes and picks.
- Drag to dismiss with Pointer Events only: below 4px it is still a press; a
clearly horizontal move hands the gesture back for good; on the first decisive
move call setPointerCapture on the element the gesture started on, so a finger
that slides off the header keeps steering and still delivers pointerup. Track a
smoothed velocity; release past 35% of the panel height, or above 0.5px/ms,
dismisses, otherwise it springs back. A cancelled gesture always springs back.
touch-action: none on the drag zone; touch-pan-y + overscroll-contain on the
list, so its bounce never chains to the screen behind it.
- Every gesture has an equal: Close, Esc (handled on the panel with
stopPropagation, never a window listener, or one Esc closes two layers) and a
backdrop tap all dismiss.
- The list is role="listbox" of role="option" buttons with aria-selected and a
roving tabindex (the selected row, or the first match). Arrow Up/Down walk the
rows, Home/End jump, Arrow Up off the top row steps into the search box, Enter
and Space pick. Search matches name, ISO code and dial code with or without
"+"; zero matches renders a muted row, not an empty box.
- Focus: opening moves focus to the SELECTED row (not the search box, which would
raise the keyboard over the sheet) and scrolls it into view. Closing hands
focus back to the trigger — except after a pick, where the number field is the
deliberate successor so typing carries on. Never let focus fall to <body>.
Tab is trapped inside the panel, because aria-modal claims the rest is
unreachable.
- The picker portals to document.body and covers the screen; pass `container` (a
positioned, overflow-hidden element) and the layer switches from fixed to
absolute so it can be framed in a preview. Only the body-level sheet takes the
scroll lock, and that lock counts nesting through document.body dataset keys so
independent copies of this pattern cooperate.
- One-shot guards are refs read AND written inside the handler: a double tap on a
row must not pick twice.
- Cleanup: the settle timeout, the drag rAF, the announce timeout, the matchMedia
subscription and the scroll lock are all released on unmount and on dependency
change.
Rendering & styling
- Semantic tokens only: bg-background / bg-card / bg-muted / text-foreground /
text-muted-foreground / border / ring / bg-accent, destructive only for real
errors, and the one highest-priority control INVERTS (bg-foreground
text-background) instead of taking a colour. No hex, rgb or oklch anywhere.
- Monochrome, small and tight: field 48px tall with 15px tabular-nums digits,
row / card titles 13–14px semibold, hints 11px. Radius ladder rounded-2xl for
the sheet, rounded-lg for fields and rows, rounded for chips.
- Every hit area is at least 44px: the trigger, the clear button, the close
button and each country row.
- Safe area: the sheet pads with
max(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)), 0.5rem)
(and the same for left / right), so the last country clears the home indicator.
Reading the custom property first lets a device-frame preview simulate it.
- Accessibility: aria-invalid on the field, aria-describedby to the hint line,
and a polite sr-only live region that speaks only twice — when the country
changes and when the number first becomes complete or goes over length. Never
the native `disabled` attribute (it blurs a focused node): use aria-disabled
plus readOnly and guard every handler.
- cn() merges every className.
Customization levers
- Country data: swap the exported list for a full ISO set, the three markets you
ship in, or a traffic-ordered one; only code / name / dialCode / groups /
length / flag / trunkPrefix are read.
- Grouping: `groups` is the whole formatter — [3,3,4] gives "415 555 2671",
[1,2,2,2,2] gives the French pairs. `separator` swaps spaces for hyphens or
thin spaces; drop `groups` entirely for a country you have no format for.
- Variants: pick "inline" inside a busy form, "stacked" for a dedicated screen,
"display" for the one-question sign-in; add a fourth by changing only the
wrapper layout — the field logic is variant-agnostic.
- Flags: replace the emoji span with an SVG sprite or leave `flag` off and get
the ISO-code chip, which is the safer choice on Windows.
- Picker density: raise the row height, add a per-region section header, or drop
the search box when your list is under a dozen entries.
- Copy: `labels` covers every string, formatProgress / formatOver own the hint
arithmetic, and `hint` / `errorText` carry your own compliance line.
- Validation: keep the derived status for "how much is typed", drive `invalid`
from your schema (isValidPhoneNumber(detail.e164)) for "is it real".Concepts
- Digit-space caret — every edit is translated into "how many digits sit behind the caret", regrouped, then translated back. It is the only coordinate system that survives reformatting, and it is why typing in the middle of a number does not fling the caret to the end.
- Separator hop — backspacing a space would regroup to the identical string, so the field would look frozen. When the digits did not change but the text got shorter, the deletion is pushed onto the neighbouring digit, on the side the
inputTypesays the key reached. - Bulk-only rewriting — a paste, a drop or an autofill is normalised (dial code, "00" prefix and trunk zero all stripped); a single keystroke never is. A field that edits what you just typed is one users fight.
- Value is digits, grouping is a view —
nationalstores no spaces, so switching country reformats the same number instead of mangling it, and the API always gets a clean+<dial><digits>string. - Sheet over dropdown — the country list rises off the bottom edge into the thumb arc with 48px rows and a safe-area inset, instead of hanging below a field at the top of the screen where no thumb reaches; the flick-down dismissal has Close, Esc and the backdrop as equals.
- Deliberate focus successor — closing the sheet returns focus to the trigger, but picking a country hands it to the number field, so the keyboard stays up and typing carries straight on. Nothing is ever allowed to fall back to
<body>.
Inline Picker Row
A settings row that expands in place to reveal its picker — slide a thumb across the options and lift to choose, instead of pushing a whole screen.
Answer Metric Card
An in-chat metric card for a phone — headline, delta chip and a thumb-scrubbed sparkline, bar row or intensity strip whose readout is hoisted into the header, out from under the finger.