Inputs & Formsv0.1.0
Phone Input
Country selector with live number formatting.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/phone-input.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --success, which shadcn does not define by default. Add it to your global CSS:
app/globals.css:root { --success: #15803d; } .dark { --success: #4ade80; } @theme inline { --color-success: var(--success); }Copy the source
components/ui/phone-input/phone-input.tsx"use client"; import { useId, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ComponentProps, type KeyboardEvent, } from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Popover } from "radix-ui"; import { Check, ChevronDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease, overlayVariants, spring } from "@/lib/motion"; export interface PhoneCountry { /** ISO 3166-1 alpha-2 code. */ code: string; /** English name, used for display and search. */ name: string; /** Calling code without "+". */ dial: string; /** National format; "#" is a digit. */ format: string; /** Digits needed before the number counts as complete. */ minDigits: number; } /** A compact list with simplified national formats. Use libphonenumber for full validation. */ export const phoneCountries: PhoneCountry[] = [ { code: "AU", name: "Australia", dial: "61", format: "### ### ###", minDigits: 9 }, { code: "BR", name: "Brazil", dial: "55", format: "(##) #####-####", minDigits: 10 }, { code: "CA", name: "Canada", dial: "1", format: "(###) ###-####", minDigits: 10 }, { code: "FR", name: "France", dial: "33", format: "# ## ## ## ##", minDigits: 9 }, { code: "DE", name: "Germany", dial: "49", format: "### ########", minDigits: 10 }, { code: "IN", name: "India", dial: "91", format: "##### #####", minDigits: 10 }, { code: "ID", name: "Indonesia", dial: "62", format: "###-####-#####", minDigits: 9 }, { code: "JP", name: "Japan", dial: "81", format: "##-####-####", minDigits: 10 }, { code: "MY", name: "Malaysia", dial: "60", format: "##-#### ####", minDigits: 9 }, { code: "NL", name: "Netherlands", dial: "31", format: "# ## ## ## ##", minDigits: 9 }, { code: "PH", name: "Philippines", dial: "63", format: "### ### ####", minDigits: 10 }, { code: "SG", name: "Singapore", dial: "65", format: "#### ####", minDigits: 8 }, { code: "TH", name: "Thailand", dial: "66", format: "## ### ####", minDigits: 9 }, { code: "AE", name: "United Arab Emirates", dial: "971", format: "## ### ####", minDigits: 9 }, { code: "GB", name: "United Kingdom", dial: "44", format: "#### ######", minDigits: 10 }, { code: "US", name: "United States", dial: "1", format: "(###) ###-####", minDigits: 10 }, { code: "VN", name: "Vietnam", dial: "84", format: "### ### ###", minDigits: 9 }, ]; export interface PhoneDetails { /** ISO code of the selected country. */ country: string; /** Calling code without "+". */ dialCode: string; /** Digits after the calling code. */ nationalNumber: string; /** True once the number has the country's minimum digit count. */ complete: boolean; } export interface PhoneInputProps extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" | "size"> { /** Controlled number in E.164 format, e.g. "+15551234567". */ value?: string; /** Initial number in E.164 format when uncontrolled. */ defaultValue?: string; /** Called with the E.164 number ("" when empty) and parsed details. */ onValueChange?: (value: string, details: PhoneDetails) => void; /** Country selected when there is no initial number. @default "US" */ defaultCountry?: string; /** Limit and order the selectable countries by ISO code. @default every country in phoneCountries */ countries?: string[]; /** Classes for the outer field. */ className?: string; } const flag = (code: string) => String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); const digitsOf = (text: string) => text.replace(/\D/g, ""); const maxDigits = (country: PhoneCountry) => country.format.split("#").length - 1; function formatNational(digits: string, pattern: string) { let out = ""; let i = 0; for (const ch of pattern) { if (i >= digits.length) break; out += ch === "#" ? digits[i++] : ch; } return out; } function caretAfterDigits(formatted: string, count: number) { if (count <= 0) return formatted.search(/\d/) === -1 ? formatted.length : formatted.search(/\d/); let seen = 0; for (let i = 0; i < formatted.length; i++) { if (/\d/.test(formatted[i]) && ++seen === count) return i + 1; } return formatted.length; } /** "+6281234567890" → Indonesia + "81234567890". Prefers `preferred` when calling codes are shared (+1). */ function parseE164(value: string | undefined, list: PhoneCountry[], preferred?: string) { if (!value) return null; const digits = digitsOf(value); const matches = list .filter((c) => digits.startsWith(c.dial)) .sort((a, b) => b.dial.length - a.dial.length || Number(b.code === preferred) - Number(a.code === preferred)); const country = matches[0]; return country ? { country, national: digits.slice(country.dial.length).slice(0, maxDigits(country)) } : null; } export function PhoneInput({ value, defaultValue, onValueChange, defaultCountry = "US", countries, className, disabled, placeholder, "aria-label": ariaLabel, ...props }: PhoneInputProps) { const list = useMemo( () => countries ? countries.map((code) => phoneCountries.find((c) => c.code === code)).filter((c): c is PhoneCountry => Boolean(c)) : phoneCountries, [countries], ); const fallback = list.find((c) => c.code === defaultCountry) ?? list[0]; const initial = parseE164(value ?? defaultValue, list, defaultCountry); const [country, setCountry] = useState(initial?.country ?? fallback); const [digits, setDigits] = useState(initial?.national ?? ""); const [syncedValue, setSyncedValue] = useState(value); const [, forceRender] = useState(0); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null); const pendingCaret = useRef<number | null>(null); const reduceMotion = useReducedMotion(); const listId = useId(); const groupId = useId(); // Follow a controlled value when it differs from what is typed. if (value !== undefined && value !== syncedValue) { setSyncedValue(value); const current = digits ? `+${country.dial}${digits}` : ""; if (value !== current) { const parsed = parseE164(value, list, country.code); setDigits(parsed?.national ?? ""); if (parsed) setCountry(parsed.country); } } const formatted = formatNational(digits, country.format); const complete = digits.length >= country.minDigits; useLayoutEffect(() => { if (pendingCaret.current !== null && inputRef.current && document.activeElement === inputRef.current) { inputRef.current.setSelectionRange(pendingCaret.current, pendingCaret.current); } pendingCaret.current = null; }); function emit(nextCountry: PhoneCountry, nextDigits: string) { onValueChange?.(nextDigits ? `+${nextCountry.dial}${nextDigits}` : "", { country: nextCountry.code, dialCode: nextCountry.dial, nationalNumber: nextDigits, complete: nextDigits.length >= nextCountry.minDigits, }); } function handleChange(event: ChangeEvent<HTMLInputElement>) { const input = event.currentTarget; const raw = input.value; const caret = input.selectionStart ?? raw.length; // A pasted international number switches the country. if (raw.trim().startsWith("+")) { const parsed = parseE164(raw, list, country.code); if (parsed) { setCountry(parsed.country); setDigits(parsed.national); pendingCaret.current = formatNational(parsed.national, parsed.country.format).length; emit(parsed.country, parsed.national); return; } } let next = digitsOf(raw); let digitsBeforeCaret = digitsOf(raw.slice(0, caret)).length; const deleting = (event.nativeEvent as InputEvent).inputType === "deleteContentBackward"; if (deleting && next === digits && digitsBeforeCaret > 0) { // Backspace hit a separator: delete the digit before it instead. next = next.slice(0, digitsBeforeCaret - 1) + next.slice(digitsBeforeCaret); digitsBeforeCaret -= 1; } // Drop a national trunk 0 (e.g. 0812… in Indonesia); +1 countries have none. if (country.dial !== "1" && next.startsWith("0") && !digits.startsWith("0")) { next = next.slice(1); digitsBeforeCaret = Math.max(0, digitsBeforeCaret - 1); } next = next.slice(0, maxDigits(country)); pendingCaret.current = caretAfterDigits(formatNational(next, country.format), digitsBeforeCaret); if (next === digits) { // React restores the old text without re-rendering; render anyway so the caret is restored too. forceRender((n) => n + 1); return; } setDigits(next); emit(country, next); } const filtered = useMemo(() => { const q = query.trim().toLowerCase().replace(/^\+/, ""); if (!q) return list; return list.filter((c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase() === q || c.dial.startsWith(q)); }, [list, query]); function choose(next: PhoneCountry) { const trimmed = digits.slice(0, maxDigits(next)); setCountry(next); setDigits(trimmed); setOpen(false); emit(next, trimmed); } function onSearchKey(event: KeyboardEvent<HTMLInputElement>) { if (!filtered.length) return; const moves: Record<string, number> = { ArrowDown: (active + 1) % filtered.length, ArrowUp: (active - 1 + filtered.length) % filtered.length, Home: 0, End: filtered.length - 1, }; if (event.key in moves) { event.preventDefault(); setActive(moves[event.key]); document.getElementById(`${listId}-${moves[event.key]}`)?.scrollIntoView({ block: "nearest" }); } else if (event.key === "Enter") { event.preventDefault(); choose(filtered[active]); } } const swap = reduceMotion ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: { opacity: 0, y: 8, filter: "blur(3px)" }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, y: -8, filter: "blur(3px)" }, }; return ( <div className={cn( "flex h-10 w-full items-stretch rounded-md border border-input bg-background text-sm text-foreground shadow-xs", "transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20", "has-[input[aria-invalid=true]]:border-destructive", disabled && "pointer-events-none opacity-50", className, )} > <Popover.Root open={open} onOpenChange={(next) => { setOpen(next); if (next) { setQuery(""); setActive(Math.max(0, list.indexOf(country))); } }} > <Popover.Trigger disabled={disabled} // Starts with the visible dial code, as the visible label should lead the accessible name. aria-label={`+${country.dial}, ${country.name} country code`} aria-haspopup="listbox" className="relative flex shrink-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-l-[calc(var(--radius)-1px)] border-r border-input pr-2 pl-3 outline-none transition-colors hover:bg-accent focus-visible:bg-accent" > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={country.code} variants={swap} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.quick, ease: ease.out }} className="flex items-center gap-1.5" > {/* Drawn with CSS content, so the button's visible text is just the dial code (which is in its name). */} <span className="text-base leading-none before:content-[attr(data-flag)]" data-flag={flag(country.code)} aria-hidden="true" /> <span className="text-muted-foreground tabular-nums">+{country.dial}</span> </motion.span> </AnimatePresence> <ChevronDown aria-hidden="true" className={cn("size-3.5 text-muted-foreground transition-transform duration-200", open && "rotate-180")} /> </Popover.Trigger> <AnimatePresence> {open ? ( <Popover.Portal forceMount> <Popover.Content forceMount asChild align="start" sideOffset={6} collisionPadding={12} onOpenAutoFocus={(event) => { event.preventDefault(); searchRef.current?.focus(); }} onCloseAutoFocus={(event) => { event.preventDefault(); inputRef.current?.focus(); }} > <motion.div variants={overlayVariants(reduceMotion)} initial="hidden" animate="visible" exit="exit" style={{ transformOrigin: "var(--radix-popover-content-transform-origin)" }} className="z-50 w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-[10px] border border-border bg-popover text-popover-foreground shadow-lg outline-none" > <div className="flex items-center gap-2 border-b border-border px-3"> <Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <input ref={searchRef} value={query} onChange={(event) => { setQuery(event.target.value); setActive(0); }} onKeyDown={onSearchKey} role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={filtered.length ? `${listId}-${active}` : undefined} aria-label="Search countries" placeholder="Search countries…" className="h-10 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" /> </div> <LayoutGroup id={groupId}> <div id={listId} role="listbox" aria-label="Countries" className="max-h-64 overflow-y-auto overscroll-contain p-1"> {filtered.length === 0 ? ( <p className="px-2.5 py-6 text-center text-[13px] text-muted-foreground">No countries found.</p> ) : ( filtered.map((option, index) => { const highlighted = index === active; const selected = option.code === country.code; return ( <div key={option.code} id={`${listId}-${index}`} role="option" aria-selected={highlighted} onPointerMove={() => !highlighted && setActive(index)} onClick={() => choose(option)} className="relative flex h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-[13px]" > {highlighted ? ( <motion.span layoutId="active-row" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-md bg-accent" /> ) : null} <span className="relative text-base leading-none" aria-hidden="true"> {flag(option.code)} </span> <span className="relative min-w-0 flex-1 truncate text-foreground">{option.name}</span> <span className="relative text-muted-foreground tabular-nums">+{option.dial}</span> <Check aria-hidden="true" className={cn("relative size-3.5 text-foreground", selected ? "opacity-100" : "opacity-0")} /> </div> ); }) )} </div> </LayoutGroup> </motion.div> </Popover.Content> </Popover.Portal> ) : null} </AnimatePresence> </Popover.Root> <span className="relative flex min-w-0 flex-1 items-center"> <input ref={inputRef} type="tel" inputMode="tel" autoComplete="tel-national" value={formatted} onChange={handleChange} disabled={disabled} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : "Phone number")} placeholder={placeholder ?? formatNational("2015550123456789".slice(0, maxDigits(country)), country.format)} className="h-full w-full min-w-0 bg-transparent pr-9 pl-3 tabular-nums outline-none placeholder:text-muted-foreground/70" {...props} /> <AnimatePresence> {complete ? ( <motion.span aria-hidden="true" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.5 }} transition={reduceMotion ? { duration: duration.quick } : spring.bouncy} className="pointer-events-none absolute right-3 text-success" > <svg viewBox="0 0 24 24" fill="none" className="size-4"> <motion.path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: duration.base, ease: ease.out }} /> </svg> </motion.span> ) : null} </AnimatePresence> </span> </div> ); }
Usage
import { PhoneInput } from "@/components/ui/phone-input/phone-input";
export function ContactPhone() {
const [phone, setPhone] = useState("");
return <PhoneInput aria-label="Phone number" defaultCountry="ID" value={phone} onValueChange={setPhone} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Controlled number in E.164 format, e.g. "+15551234567". |
defaultValue | string | – | Initial number in E.164 format when uncontrolled. |
onValueChange | (value: string, details: PhoneDetails) => void | – | Called with the E.164 number ("" when empty) and parsed details. |
defaultCountry | string | "US" | Country selected when there is no initial number. |
countries | string[] | every country in phoneCountries | Limit and order the selectable countries by ISO code. |
className | string | – | Classes for the outer field. |
Also accepts every native <input> attribute, including ref and className.
- onValueChange receives E.164 ("+6281234567890") plus the country, national number and whether it is complete.
- Formats are simplified per country and cover the common mobile layout. Validate on the server with libphonenumber if you need carrier-grade accuracy.
- A leading trunk 0 is dropped for countries outside +1, so local habits like 0812… just work.
Accessibility
- The country button is labelled with the current choice ("Country: Indonesia (+62)") and opens a searchable listbox.
- In the list, arrow keys, Home and End move the highlight, Enter picks a country and Escape closes it; focus returns to the number field.
- The number field is type="tel" with autocomplete="tel-national" (labelled "Phone number" unless you pass a label), so mobile keyboards and autofill work.
- The caret stays with the digit you typed as separators appear, and Backspace over a separator deletes the digit before it.
- With reduced motion enabled, the popover and country swap fade without scaling or sliding.