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 · lighticonslucide-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 };