Phone Input
Original ยท freephone input with country prefix select
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/inp-phone.json"use client"
import { useEffect, useId, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type Country = {
code: string
name: string
dial: string
flag: string
format?: string
}
const COUNTRIES: Country[] = [
{ code: "US", name: "United States", dial: "+1", flag: "๐บ๐ธ", format: "(###) ###-####" },
{ code: "CA", name: "Canada", dial: "+1", flag: "๐จ๐ฆ", format: "(###) ###-####" },
{ code: "GB", name: "United Kingdom", dial: "+44", flag: "๐ฌ๐ง", format: "#### ######" },
{ code: "IE", name: "Ireland", dial: "+353", flag: "๐ฎ๐ช", format: "## ### ####" },
{ code: "IN", name: "India", dial: "+91", flag: "๐ฎ๐ณ", format: "##### #####" },
{ code: "PK", name: "Pakistan", dial: "+92", flag: "๐ต๐ฐ", format: "### #######" },
{ code: "BD", name: "Bangladesh", dial: "+880", flag: "๐ง๐ฉ", format: "####-######" },
{ code: "AU", name: "Australia", dial: "+61", flag: "๐ฆ๐บ", format: "### ### ###" },
{ code: "NZ", name: "New Zealand", dial: "+64", flag: "๐ณ๐ฟ", format: "## ### ####" },
{ code: "DE", name: "Germany", dial: "+49", flag: "๐ฉ๐ช", format: "#### #######" },
{ code: "FR", name: "France", dial: "+33", flag: "๐ซ๐ท", format: "# ## ## ## ##" },
{ code: "ES", name: "Spain", dial: "+34", flag: "๐ช๐ธ", format: "### ## ## ##" },
{ code: "IT", name: "Italy", dial: "+39", flag: "๐ฎ๐น", format: "### ### ####" },
{ code: "NL", name: "Netherlands", dial: "+31", flag: "๐ณ๐ฑ", format: "# ########" },
{ code: "SE", name: "Sweden", dial: "+46", flag: "๐ธ๐ช", format: "##-### ## ##" },
{ code: "CH", name: "Switzerland", dial: "+41", flag: "๐จ๐ญ", format: "## ### ## ##" },
{ code: "BR", name: "Brazil", dial: "+55", flag: "๐ง๐ท", format: "(##) #####-####" },
{ code: "MX", name: "Mexico", dial: "+52", flag: "๐ฒ๐ฝ", format: "### ### ####" },
{ code: "JP", name: "Japan", dial: "+81", flag: "๐ฏ๐ต", format: "## #### ####" },
{ code: "CN", name: "China", dial: "+86", flag: "๐จ๐ณ", format: "### #### ####" },
{ code: "SG", name: "Singapore", dial: "+65", flag: "๐ธ๐ฌ", format: "#### ####" },
{ code: "AE", name: "United Arab Emirates", dial: "+971", flag: "๐ฆ๐ช", format: "## ### ####" },
{ code: "SA", name: "Saudi Arabia", dial: "+966", flag: "๐ธ๐ฆ", format: "## ### ####" },
{ code: "ZA", name: "South Africa", dial: "+27", flag: "๐ฟ๐ฆ", format: "## ### ####" },
{ code: "NG", name: "Nigeria", dial: "+234", flag: "๐ณ๐ฌ", format: "### ### ####" },
{ code: "ID", name: "Indonesia", dial: "+62", flag: "๐ฎ๐ฉ", format: "###-###-###" },
]
const SAMPLE = "2025550138462"
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ")
}
function digitCapFor(country: Country): number {
return country.format ? (country.format.match(/#/g)?.length ?? 15) : 15
}
function formatNational(digits: string, template?: string): string {
if (!template) return digits.replace(/(\d{3})(?=\d)/g, "$1 ").trim()
let out = ""
let di = 0
for (const ch of template) {
if (ch === "#") {
if (di < digits.length) {
out += digits[di]
di += 1
} else {
break
}
} else if (di < digits.length) {
out += ch
} else {
break
}
}
if (di < digits.length) out += digits.slice(di)
return out
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth={1.75} />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" />
</svg>
)
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M4 10.5l4 4 8-9" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function AlertIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth={1.75} />
<path d="M10 6v5" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" />
<circle cx="10" cy="13.75" r="1" fill="currentColor" />
</svg>
)
}
function AnimatedCheck() {
return (
<span className="relative inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm shadow-emerald-500/30">
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" aria-hidden="true">
<path
className="inpphone-check-path"
d="M5 12.5l4.2 4.4L19 6.6"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
style={{ strokeDasharray: 26 }}
/>
</svg>
</span>
)
}
function PhoneField(props: {
label: string
helper?: string
defaultCountryCode?: string
initialDigits?: string
initialTouched?: boolean
required?: boolean
}) {
const reduce = useReducedMotion()
const uid = useId()
const inputId = `${uid}-tel`
const helperId = `${uid}-help`
const errorId = `${uid}-err`
const listId = `${uid}-list`
const triggerId = `${uid}-trigger`
const [country, setCountry] = useState<Country>(
() => COUNTRIES.find((c) => c.code === (props.defaultCountryCode ?? "US")) ?? COUNTRIES[0],
)
const [digits, setDigits] = useState<string>(props.initialDigits ?? "")
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [activeIndex, setActiveIndex] = useState(0)
const [touched, setTouched] = useState<boolean>(props.initialTouched ?? false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const telRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLLIElement | null>>([])
const digitCap = useMemo(() => digitCapFor(country), [country])
const display = useMemo(() => formatNational(digits, country.format), [digits, country])
const placeholder = useMemo(
() => (country.format ? formatNational(SAMPLE.slice(0, digitCap), country.format) : "555 0138"),
[country, digitCap],
)
const isEmpty = digits.length === 0
const isValid = country.format ? digits.length === digitCap : digits.length >= 7 && digits.length <= 15
const showError = touched && !isEmpty && !isValid
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return COUNTRIES
const bare = q.replace("+", "")
return COUNTRIES.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.code.toLowerCase().includes(q) ||
c.dial.replace("+", "").includes(bare),
)
}, [query])
useEffect(() => {
if (open) searchRef.current?.focus()
}, [open])
useEffect(() => {
setActiveIndex(0)
}, [query, open])
useEffect(() => {
if (open) optionRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" })
}, [activeIndex, open])
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery("")
}
}
document.addEventListener("pointerdown", onDown)
return () => document.removeEventListener("pointerdown", onDown)
}, [open])
const selectCountry = (c: Country) => {
const cap = digitCapFor(c)
setCountry(c)
setDigits((d) => d.slice(0, cap))
setOpen(false)
setQuery("")
window.requestAnimationFrame(() => telRef.current?.focus())
}
const frame = showError
? "border-rose-400 dark:border-rose-500/70 focus-within:border-rose-500 focus-within:ring-2 focus-within:ring-rose-500/30"
: isValid && !isEmpty
? "border-emerald-400/80 dark:border-emerald-500/60 focus-within:border-emerald-500 focus-within:ring-2 focus-within:ring-emerald-500/25"
: "border-slate-300 dark:border-slate-700 focus-within:border-indigo-500 dark:focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/30"
const activeOptionId = filtered[activeIndex] ? `${listId}-opt-${filtered[activeIndex].code}` : undefined
return (
<div className="w-full">
<div className="flex items-center justify-between gap-2">
<label htmlFor={inputId} className="text-sm font-medium text-slate-800 dark:text-slate-200">
{props.label}
{props.required ? <span className="text-rose-500"> *</span> : null}
</label>
{isValid && !isEmpty ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<CheckIcon className="h-3.5 w-3.5" />
Verified format
</span>
) : null}
</div>
<div className="relative mt-1.5" ref={rootRef}>
<div className={cx("flex items-stretch rounded-xl border bg-white transition-colors dark:bg-slate-950", frame)}>
<button
type="button"
id={triggerId}
ref={triggerRef}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={`Country calling code: ${country.name}, ${country.dial}. Activate to change.`}
onClick={() => setOpen((o) => !o)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
setOpen(true)
}
}}
className="flex items-center gap-1.5 rounded-l-xl px-3 py-2.5 text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-slate-200 dark:hover:bg-slate-800/60"
>
<span aria-hidden="true" className="text-lg leading-none">
{country.flag}
</span>
<span className="text-sm font-medium tabular-nums">{country.dial}</span>
<ChevronIcon className={cx("h-4 w-4 text-slate-400 transition-transform duration-200", open && "rotate-180")} />
</button>
<span aria-hidden="true" className="my-2 w-px bg-slate-200 dark:bg-slate-800" />
<input
id={inputId}
ref={telRef}
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel-national"
value={display}
onChange={(e) => {
setDigits(e.target.value.replace(/\D/g, "").slice(0, digitCap))
}}
onBlur={() => setTouched(true)}
placeholder={placeholder}
aria-invalid={showError || undefined}
aria-describedby={cx(props.helper ? helperId : "", showError ? errorId : "") || undefined}
className="min-w-0 flex-1 bg-transparent px-3 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<div className="flex items-center pr-3">
{isValid && !isEmpty ? (
<AnimatedCheck />
) : showError ? (
<AlertIcon className="h-5 w-5 text-rose-500" />
) : null}
</div>
</div>
<AnimatePresence>
{open ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: reduce ? 0.12 : 0.18, ease: [0.16, 1, 0.3, 1] }}
style={{ transformOrigin: "top" }}
className="absolute left-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40 sm:w-80"
>
<div className="border-b border-slate-100 p-2 dark:border-slate-800">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
ref={searchRef}
type="text"
role="combobox"
aria-expanded
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
aria-label="Search countries by name or dial code"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Home") {
e.preventDefault()
setActiveIndex(0)
} else if (e.key === "End") {
e.preventDefault()
setActiveIndex(Math.max(filtered.length - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
const c = filtered[activeIndex]
if (c) selectCountry(c)
} else if (e.key === "Escape") {
e.preventDefault()
setOpen(false)
setQuery("")
triggerRef.current?.focus()
} else if (e.key === "Tab") {
setOpen(false)
setQuery("")
}
}}
placeholder="Search countriesโฆ"
className="w-full rounded-lg border border-slate-200 bg-slate-50 py-2 pl-8 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
</div>
<ul
id={listId}
role="listbox"
aria-label="Countries"
className="max-h-60 overflow-y-auto py-1"
>
{filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-slate-400 dark:text-slate-500">
No countries match โ{query}โ.
</li>
) : (
filtered.map((c, idx) => {
const selected = c.code === country.code
const active = idx === activeIndex
return (
<li
key={c.code}
id={`${listId}-opt-${c.code}`}
role="option"
aria-selected={selected}
ref={(el) => {
optionRefs.current[idx] = el
}}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => selectCountry(c)}
className={cx(
"flex cursor-pointer items-center gap-3 px-3 py-2 text-sm",
active ? "bg-indigo-50 dark:bg-indigo-500/15" : "",
)}
>
<span aria-hidden="true" className="text-lg leading-none">
{c.flag}
</span>
<span
className={cx(
"flex-1 truncate text-slate-700 dark:text-slate-200",
selected && "font-semibold",
)}
>
{c.name}
</span>
<span className="tabular-nums text-slate-400 dark:text-slate-500">{c.dial}</span>
{selected ? <CheckIcon className="h-4 w-4 text-indigo-600 dark:text-indigo-400" /> : null}
</li>
)
})
)}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="mt-1.5 flex items-start justify-between gap-3">
<p
id={showError ? errorId : helperId}
role={showError ? "alert" : undefined}
className={cx(
"text-xs leading-relaxed",
showError ? "text-rose-600 dark:text-rose-400" : "text-slate-500 dark:text-slate-400",
)}
>
{showError ? `Enter a complete phone number for ${country.name}.` : props.helper}
</p>
<p aria-live="polite" className="shrink-0 text-right text-xs text-slate-400 dark:text-slate-500">
{isEmpty ? null : (
<>
Sends as{" "}
<span className="font-medium tabular-nums text-slate-600 dark:text-slate-300">
{country.dial} {display}
</span>
</>
)}
</p>
</div>
</div>
)
}
export default function InpPhone() {
return (
<section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:py-24">
<style>{`
@keyframes inpphone-check-draw {
from { stroke-dashoffset: 26; }
to { stroke-dashoffset: 0; }
}
.inpphone-check-path {
animation: inpphone-check-draw 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.inpphone-check-path {
animation: none;
stroke-dashoffset: 0;
}
}
`}</style>
<div className="mx-auto max-w-2xl">
<header className="mb-10 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Inputs ยท Phone
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50 sm:text-3xl">
International phone field
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-500 dark:text-slate-400">
A searchable country-code picker with live formatting, E.164 preview, full keyboard control, and inline
validation.
</p>
</header>
<div className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20 sm:p-6">
<PhoneField
label="Mobile number"
helper="We'll text a 6-digit code to confirm it's you."
defaultCountryCode="US"
required
/>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20 sm:p-6">
<PhoneField
label="Recovery phone"
helper="Used only if you get locked out of your account."
defaultCountryCode="GB"
initialDigits="2079460958"
/>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20 sm:p-6">
<PhoneField
label="Team alert line"
helper="Critical incidents are announced to this number."
defaultCountryCode="US"
initialDigits="202555"
initialTouched
/>
</div>
</div>
<p className="mt-8 text-center text-xs text-slate-400 dark:text-slate-500">
Try it: open the code picker, then use{" "}
<kbd className="rounded border border-slate-300 bg-white px-1 font-sans dark:border-slate-700 dark:bg-slate-800">
โ
</kbd>{" "}
<kbd className="rounded border border-slate-300 bg-white px-1 font-sans dark:border-slate-700 dark:bg-slate-800">
โ
</kbd>{" "}
to move,{" "}
<kbd className="rounded border border-slate-300 bg-white px-1 font-sans dark:border-slate-700 dark:bg-slate-800">
Enter
</kbd>{" "}
to select,{" "}
<kbd className="rounded border border-slate-300 bg-white px-1 font-sans dark:border-slate-700 dark:bg-slate-800">
Esc
</kbd>{" "}
to close.
</p>
</div>
</section>
)
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all โ
Basic Set Input
Originaldefault, hover, focus, disabled, error text inputs

Floating Label Input
Originallabel that floats up on focus/fill

Filled Input
Originalfilled material-style inputs

Underline Input
Originalminimal underline inputs with animated bar

Search Input
Originalsearch box with icon and clear

Search Suggest Input
Originalsearch with a live suggestions dropdown

Password Toggle Input
Originalpassword field with show/hide toggle

Textarea Autosize Input
Originaltextarea that grows with content

Addon Group Input
Originalinput group with leading/trailing addons

Currency Input
Originalcurrency input with formatting

Tags Input
Originaltag input, add and remove chips

OTP Input
Original6-box one-time-code input with paste

