Web InnoventixFreeCode

Phone Input

Original ยท free

phone input with country prefix select

byWeb InnoventixReact + Tailwind
inpphoneinputs
Open

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
inp-phone.tsx
"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

motion

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 quote

Similar components

Browse all โ†’