shadimage
nordaun
blockphonesource: ok · hit

Phone

A simple but clean phone input with a region selection so you dont have to worry about messing up.

npx shadcn add "https://ui.nordaun.com/r/phone.json"
free

Visual style

minimal

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

commandinput-grouppopover

Files3 · source cached 10/14/2026, 2:18:20 PM

src/components/ui/flag.tsx
"use client";

import * as React from "react";

import { countryList } from "@/lib/geo";

export type FlagCode = (typeof countryList)[number];

interface FlagProps extends React.SVGProps<SVGSVGElement> {
  code: FlagCode;
}

const flagCache = new Map<string, { viewBox: string; content: string }>();

async function loadFlag(code: string) {
  const cached = flagCache.get(code);
  if (cached) return cached;

  const response = await fetch(
    `https://cdn.jsdelivr.net/npm/@nordaun/flags@latest/flags/${code}.svg`,
  );
  const text = await response.text();
  const svg = new DOMParser()
    .parseFromString(text, "image/svg+xml")
    .querySelector("svg");

  if (!svg) throw new Error(`Invalid flag SVG: ${code}`);

  const parsed = {
    viewBox: svg.getAttribute("viewBox") ?? "0 0 36 36",
    content: svg.innerHTML,
  };

  flagCache.set(code, parsed);
  return parsed;
}

function Flag({ code, className, viewBox, ...props }: FlagProps) {
  const formattedCode = code?.toLowerCase();
  const [flag, setFlag] = React.useState(
    () => flagCache.get(formattedCode) ?? null,
  );

  React.useEffect(() => {
    let cancelled = false;

    loadFlag(formattedCode)
      .then((parsed) => {
        if (!cancelled) setFlag(parsed);
      })
      .catch(() => {
        if (!cancelled) setFlag(null);
      });

    return () => {
      cancelled = true;
    };
  }, [formattedCode]);

  return (
    <svg
      className={className}
      viewBox={viewBox ?? flag?.viewBox ?? "0 0 36 36"}
      aria-hidden
      dangerouslySetInnerHTML={flag ? { __html: flag.content } : undefined}
      {...props}
    />
  );
}

export { Flag };
src/components/ui/phone.tsx
"use client";

import parsePhoneNumberFromString, {
  CountryCode,
  E164Number,
  getCountries,
  getCountryCallingCode,
} from "libphonenumber-js";
import * as React from "react";

import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import { Flag } from "@/components/ui/flag";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { countryNames } from "@/lib/geo";
import { cn } from "@/lib/utils";

const codes: CountryCode[] = getCountries();

type PhoneProps = React.ComponentProps<"input"> & {
  value?: string;
  searchPlaceholder: string;
  emptyPlaceholder: string;
};

function Phone({
  value,
  onChange,
  placeholder,
  searchPlaceholder,
  emptyPlaceholder,
  ...props
}: PhoneProps) {
  const countries = React.useMemo(
    () =>
      [...codes].sort((a, b) => {
        return countryNames[a].localeCompare(countryNames[b]);
      }),
    [],
  );

  const [country, setCountry] = React.useState<CountryCode>("US");
  const [number, setNumber] = React.useState<string>("");
  const [open, setOpen] = React.useState(false);

  const inputRef = React.useRef<HTMLInputElement>(null);

  const handleCountryChange = (newCountry: CountryCode) => {
    setCountry(newCountry);
    setOpen(false);
    const fullNumber = parsePhoneNumberFromString(number, newCountry);
    triggerOnChange(fullNumber?.number);
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const filteredValue = e.target.value.replace(/[^0-9\-() ]/g, "");
    setNumber(filteredValue);
    const fullNumber = parsePhoneNumberFromString(filteredValue, country);
    triggerOnChange(fullNumber?.number);
  };

  const triggerOnChange = (newNumber: E164Number | undefined) => {
    if (!onChange || !inputRef.current) return;
    const syntheticEvent = {
      target: {
        ...inputRef.current,
        value: newNumber?.toString(),
      },
      currentTarget: inputRef.current,
    } as React.ChangeEvent<HTMLInputElement>;
    onChange(syntheticEvent);
  };

  const { name, className, ...groupProps } = props;

  return (
    <>
      <InputGroup className={cn(className)}>
        <InputGroupAddon
          align="inline-start"
          className="pl-1 my-0"
          onClick={(e) => {
            if (!e.currentTarget.contains(e.target as Node)) return;
          }}
        >
          <Popover open={open} onOpenChange={setOpen}>
            <PopoverTrigger
              aria-expanded={open}
              type="button"
              role="combobox"
              className="flex flex-row gap-1.5 h-8 items-center px-2 hover:bg-accent rounded-md cursor-pointer border-0 bg-transparent"
            >
              <Flag code={country} className="size-4" />
              <InputGroupText>+{getCountryCallingCode(country)}</InputGroupText>
            </PopoverTrigger>
            <PopoverContent className="w-[320px] p-0" align="start">
              <Command>
                <CommandInput placeholder={searchPlaceholder} />
                <CommandList className="mt-1">
                  <CommandEmpty>{emptyPlaceholder}</CommandEmpty>
                  <CommandGroup>
                    {countries.map((countryCode) => (
                      <CommandItem
                        key={countryCode}
                        value={`${countryNames[countryCode]} +${getCountryCallingCode(countryCode)}`}
                        onSelect={() => handleCountryChange(countryCode)}
                        className="cursor-pointer"
                      >
                        <span className="flex flex-row gap-1.5 h-5 items-center w-full">
                          <Flag code={countryCode} className="size-5" />
                          <span className="w-10 text-end">
                            +{getCountryCallingCode(countryCode)}
                          </span>
                          <span className="text-muted-foreground flex-1">
                            {countryNames[countryCode]}
                          </span>
                          {country === countryCode && (
                            <svg
                              className="ml-auto h-4 w-4"
                              fill="none"
                              stroke="currentColor"
                              viewBox="0 0 24 24"
                            >
                              <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                strokeWidth={2}
                                d="M5 13l4 4L19 7"
                              />
                            </svg>
                          )}
                        </span>
                      </CommandItem>
                    ))}
                  </CommandGroup>
                </CommandList>
              </Command>
            </PopoverContent>
          </Popover>
        </InputGroupAddon>
        <InputGroupInput
          ref={inputRef}
          type="tel"
          value={number}
          onChange={handleInputChange}
          placeholder={placeholder}
          {...groupProps}
        />
      </InputGroup>
      {name && <input type="hidden" name={name} value={value} />}
    </>
  );
}

export { Phone };
src/lib/geo.ts
const countryNames = {
  AC: "Ascension Island",
  AD: "Andorra",
  AE: "United Arab Emirates",
  AF: "Afghanistan",
  AG: "Antigua and Barbuda",
  AI: "Anguilla",
  AL: "Albania",
  AM: "Armenia",
  AO: "Angola",
  AR: "Argentina",
  AS: "American Samoa",
  AT: "Austria",
  AU: "Australia",
  AW: "Aruba",
  AX: "Åland Islands",
  AZ: "Azerbaijan",
  BA: "Bosnia and Herzegovina",
  BB: "Barbados",
  BD: "Bangladesh",
  BE: "Belgium",
  BF: "Burkina Faso",
  BG: "Bulgaria",
  BH: "Bahrain",
  BI: "Burundi",
  BJ: "Benin",
  BL: "Saint Barthélemy",
  BM: "Bermuda",
  BN: "Brunei Darussalam",
  BO: "Bolivia",
  BQ: "Bonaire",
  BR: "Brazil",
  BS: "Bahamas",
  BT: "Bhutan",
  BW: "Botswana",
  BY: "Belarus",
  BZ: "Belize",
  CA: "Canada",
  CC: "Cocos Islands",
  CD: "Republic of Congo",
  CF: "Central African Republic",
  CG: "Congo",
  CH: "Switzerland",
  CI: "Côte d'Ivoire",
  CK: "Cook Islands",
  CL: "Chile",
  CM: "Cameroon",
  CN: "China",
  CO: "Colombia",
  CR: "Costa Rica",
  CU: "Cuba",
  CV: "Cabo Verde",
  CW: "Curaçao",
  CX: "Christmas Island",
  CY: "Cyprus",
  CZ: "Czechia",
  DE: "Germany",
  DJ: "Djibouti",
  DK: "Denmark",
  DM: "Dominica",
  DO: "Dominican Republic",
  DZ: "Algeria",
  EC: "Ecuador",
  EE: "Estonia",
  EG: "Egypt",
  EH: "Western Sahara",
  ER: "Eritrea",
  ES: "Spain",
  ET: "Ethiopia",
  FI: "Finland",
  FJ: "Fiji",
  FK: "Falkland Islands",
  FM: "Micronesia",
  FO: "Faroe Islands",
  FR: "France",
  GA: "Gabon",
  GB: "United Kingdom",
  GD: "Grenada",
  GE: "Georgia",
  GF: "French Guiana",
  GG: "Guernsey",
  GH: "Ghana",
  GI: "Gibraltar",
  GL: "Greenland",
  GM: "Gambia",
  GN: "Guinea",
  GP: "Guadeloupe",
  GQ: "Equatorial Guinea",
  GR: "Greece",
  GT: "Guatemala",
  GU: "Guam",
  GW: "Guinea-Bissau",
  GY: "Guyana",
  HK: "Hong Kong",
  HN: "Honduras",
  HR: "Croatia",
  HT: "Haiti",
  HU: "Hungary",
  ID: "Indonesia",
  IE: "Ireland",
  IL: "Israel",
  IM: "Isle of Man",
  IN: "India",
  IO: "British Indian Ocean Territory",
  IQ: "Iraq",
  IR: "Iran",
  IS: "Iceland",
  IT: "Italy",
  JE: "Jersey",
  JM: "Jamaica",
  JO: "Jordan",
  JP: "Japan",
  KE: "Kenya",
  KG: "Kyrgyzstan",
  KH: "Cambodia",
  KI: "Kiribati",
  KM: "Comoros",
  KN: "Saint Kitts and Nevis",
  KP: "North Korea",
  KR: "South Korea",
  KW: "Kuwait",
  KY: "Cayman Islands",
  KZ: "Kazakhstan",
  LA: "Laos",
  LB: "Lebanon",
  LC: "Saint Lucia",
  LI: "Liechtenstein",
  LK: "Sri Lanka",
  LR: "Liberia",
  LS: "Lesotho",
  LT: "Lithuania",
  LU: "Luxembourg",
  LV: "Latvia",
  LY: "Libya",
  MA: "Morocco",
  MC: "Monaco",
  MD: "Moldova",
  ME: "Montenegro",
  MF: "Saint Martin",
  MG: "Madagascar",
  MH: "Marshall Islands",
  MK: "North Macedonia",
  ML: "Mali",
  MM: "Myanmar",
  MN: "Mongolia",
  MO: "Macao",
  MP: "Northern Mariana Islands",
  MQ: "Martinique",
  MR: "Mauritania",
  MS: "Montserrat",
  MT: "Malta",
  MU: "Mauritius",
  MV: "Maldives",
  MW: "Malawi",
  MX: "Mexico",
  MY: "Malaysia",
  MZ: "Mozambique",
  NA: "Namibia",
  NC: "New Caledonia",
  NE: "Niger",
  NF: "Norfolk Island",
  NG: "Nigeria",
  NI: "Nicaragua",
  NL: "Netherlands",
  NO: "Norway",
  NP: "Nepal",
  NR: "Nauru",
  NU: "Niue",
  NZ: "New Zealand",
  OM: "Oman",
  PA: "Panama",
  PE: "Peru",
  PF: "French Polynesia",
  PG: "Papua New Guinea",
  PH: "Philippines",
  PK: "Pakistan",
  PL: "Poland",
  PM: "Saint Pierre",
  PR: "Puerto Rico",
  PS: "Palestine",
  PT: "Portugal",
  PW: "Palau",
  PY: "Paraguay",
  QA: "Qatar",
  RE: "Réunion",
  RO: "Romania",
  RS: "Serbia",
  RU: "Russia",
  RW: "Rwanda",
  SA: "Saudi Arabia",
  SB: "Solomon Islands",
  SC: "Seychelles",
  SD: "Sudan",
  SE: "Sweden",
  SG: "Singapore",
  SH: "Saint Helena",
  SI: "Slovenia",
  SJ: "Svalbard",
  SK: "Slovakia",
  SL: "Sierra Leone",
  SM: "San Marino",
  SN: "Senegal",
  SO: "Somalia",
  SR: "Suriname",
  SS: "South Sudan",
  ST: "Sao Tome and Principe",
  SV: "El Salvador",
  SX: "Sint Maarten",
  SY: "Syria",
  SZ: "Eswatini",
  TA: "Tristan da Cunha",
  TC: "Turks and Caicos",
  TD: "Chad",
  TG: "Togo",
  TH: "Thailand",
  TJ: "Tajikistan",
  TK: "Tokelau",
  TL: "Timor-Leste",
  TM: "Turkmenistan",
  TN: "Tunisia",
  TO: "Tonga",
  TR: "Türkiye",
  TT: "Trinidad and Tobago",
  TV: "Tuvalu",
  TW: "Taiwan",
  TZ: "Tanzania",
  UA: "Ukraine",
  UG: "Uganda",
  US: "United States",
  UY: "Uruguay",
  UZ: "Uzbekistan",
  VA: "Vatican City",
  VC: "Saint Vincent and the Grenadines",
  VE: "Venezuela",
  VG: "British Virgin Islands",
  VI: "US Virgin Islands",
  VN: "Vietnam",
  VU: "Vanuatu",
  WF: "Wallis and Futuna",
  WS: "Samoa",
  XK: "Kosovo",
  YE: "Yemen",
  YT: "Mayotte",
  ZA: "South Africa",
  ZM: "Zambia",
  ZW: "Zimbabwe",
};

const countryList = Object.keys(countryNames);

export { countryList, countryNames };

More from nordaun

Browse all components in this library →