Skip to content

Phone Input

Country selector with live number formatting.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/phone-input.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
valuestring–Controlled number in E.164 format, e.g. "+15551234567".
defaultValuestring–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.
defaultCountrystring"US"Country selected when there is no initial number.
countriesstring[]every country in phoneCountriesLimit and order the selectable countries by ISO code.
classNamestring–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.