Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Check, ChevronDown, CircleAlert, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Rise / settle / exit duration (ms). Also how long the exit is held before the picker unmounts. */
const SETTLE_MS = 280
/** Movement (px) on the drag zone before a press becomes a drag. Below it, a press is still a press. */
const DRAG_START_PX = 4
/** Released past this fraction of the picker's own height it dismisses; below it, it springs back. */
const CLOSE_RATIO = 0.35
/** Fling threshold (px/ms): a fast flick down dismisses from four pixels in. */

Installation

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

Prompt

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 inputType says 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 viewnational stores 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>.

On This Page