Phone Input
Country dial-code dropdown with E.164 output. Compound and single-prop APIs.
Example
Installation
npx shadcn@latest add https://hirael.com/r/phone-input.jsonAPI
<PhoneInput />
+ native element props| Prop | Type | Default |
|---|---|---|
defaultValue | string | (readonly string[] & string) | — |
id | string | — |
value | string | — |
onValueChange | ((e164: string) => void) | — |
defaultCountry | string | "US" |
disabled | boolean | — |
children | React.ReactNode | — |
<PhoneInputCountrySelect />
+ native element propsNo props of its own — forwards everything to the underlying element.
<PhoneInputField />
+ native element propsNo props of its own — forwards everything to the underlying element.
Component source
"use client";
import * as React from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/hirael/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/hirael/ui/command";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/hirael/ui/input-group";
export type Country = {
iso2: string;
name: string;
dialCode: string;
};
export const COUNTRIES: readonly Country[] = [
{ iso2: "US", name: "United States", dialCode: "+1" },
{ iso2: "CA", name: "Canada", dialCode: "+1" },
{ iso2: "MX", name: "Mexico", dialCode: "+52" },
{ iso2: "BR", name: "Brazil", dialCode: "+55" },
{ iso2: "AR", name: "Argentina", dialCode: "+54" },
{ iso2: "GB", name: "United Kingdom", dialCode: "+44" },
{ iso2: "IE", name: "Ireland", dialCode: "+353" },
{ iso2: "FR", name: "France", dialCode: "+33" },
{ iso2: "DE", name: "Germany", dialCode: "+49" },
{ iso2: "ES", name: "Spain", dialCode: "+34" },
{ iso2: "IT", name: "Italy", dialCode: "+39" },
{ iso2: "NL", name: "Netherlands", dialCode: "+31" },
{ iso2: "SE", name: "Sweden", dialCode: "+46" },
{ iso2: "NO", name: "Norway", dialCode: "+47" },
{ iso2: "DK", name: "Denmark", dialCode: "+45" },
{ iso2: "PL", name: "Poland", dialCode: "+48" },
{ iso2: "CH", name: "Switzerland", dialCode: "+41" },
{ iso2: "AT", name: "Austria", dialCode: "+43" },
{ iso2: "PT", name: "Portugal", dialCode: "+351" },
{ iso2: "IN", name: "India", dialCode: "+91" },
{ iso2: "CN", name: "China", dialCode: "+86" },
{ iso2: "JP", name: "Japan", dialCode: "+81" },
{ iso2: "KR", name: "South Korea", dialCode: "+82" },
{ iso2: "AU", name: "Australia", dialCode: "+61" },
{ iso2: "NZ", name: "New Zealand", dialCode: "+64" },
] as const;
function findCountry(iso2: string): Country | undefined {
return COUNTRIES.find((c) => c.iso2 === iso2.toUpperCase());
}
function digitsOnly(input: string): string {
return input.replace(/\D/g, "");
}
function parseE164(
value: string | undefined,
fallback: Country,
): { country: Country; national: string } {
if (!value) return { country: fallback, national: "" };
const trimmed = value.trim();
if (!trimmed.startsWith("+")) {
return { country: fallback, national: digitsOnly(trimmed) };
}
const sorted = [...COUNTRIES].sort(
(a, b) => b.dialCode.length - a.dialCode.length,
);
for (const c of sorted) {
if (trimmed.startsWith(c.dialCode)) {
return {
country: c,
national: digitsOnly(trimmed.slice(c.dialCode.length)),
};
}
}
return { country: fallback, national: digitsOnly(trimmed) };
}
type Ctx = {
id: string;
country: Country;
setCountry: (next: Country) => void;
national: string;
setNational: (next: string) => void;
disabled?: boolean;
open: boolean;
setOpen: (next: boolean) => void;
};
const PhoneInputContext = React.createContext<Ctx | null>(null);
function usePhoneInput() {
const ctx = React.useContext(PhoneInputContext);
if (!ctx) {
throw new Error(
"PhoneInput compound parts must be used inside <PhoneInput>",
);
}
return ctx;
}
export type PhoneInputProps = Omit<React.ComponentProps<"div">, "children"> & {
id?: string;
value?: string;
defaultValue?: string;
onValueChange?: (e164: string) => void;
defaultCountry?: string;
disabled?: boolean;
children?: React.ReactNode;
};
function PhoneInput({
id,
value: valueProp,
defaultValue,
onValueChange,
defaultCountry = "US",
disabled,
className,
children,
...props
}: PhoneInputProps) {
const reactId = React.useId();
const fieldId = id ?? reactId;
const fallback = React.useMemo<Country>(
() => findCountry(defaultCountry) ?? COUNTRIES[0],
[defaultCountry],
);
const [country, setCountryState] = React.useState<Country>(
() => parseE164(valueProp ?? defaultValue, fallback).country,
);
const [national, setNationalState] = React.useState<string>(
() => parseE164(valueProp ?? defaultValue, fallback).national,
);
const [open, setOpen] = React.useState(false);
const isControlled = valueProp !== undefined;
const lastSeen = React.useRef<string | undefined>(valueProp);
React.useEffect(() => {
if (!isControlled) return;
if (lastSeen.current === valueProp) return;
lastSeen.current = valueProp;
const parsed = parseE164(valueProp, fallback);
setCountryState(parsed.country);
setNationalState(parsed.national);
}, [isControlled, valueProp, fallback]);
const emit = React.useCallback(
(c: Country, n: string) => {
const digits = digitsOnly(n);
const e164 = digits ? `${c.dialCode}${digits}` : "";
lastSeen.current = e164;
onValueChange?.(e164);
},
[onValueChange],
);
const setCountry = React.useCallback(
(next: Country) => {
setCountryState(next);
emit(next, national);
},
[emit, national],
);
const setNational = React.useCallback(
(next: string) => {
setNationalState(next);
emit(country, next);
},
[emit, country],
);
const ctx = React.useMemo<Ctx>(
() => ({
id: fieldId,
country,
setCountry,
national,
setNational,
disabled,
open,
setOpen,
}),
[fieldId, country, setCountry, national, setNational, disabled, open],
);
return (
<PhoneInputContext.Provider value={ctx}>
<InputGroup
data-slot="phone-input"
data-disabled={disabled || undefined}
className={cn(disabled && "opacity-60", className)}
{...props}
>
{children}
</InputGroup>
</PhoneInputContext.Provider>
);
}
type PhoneInputCountrySelectProps = Omit<
React.ComponentProps<"button">,
"children" | "type"
>;
function PhoneInputCountrySelect({
className,
...props
}: PhoneInputCountrySelectProps) {
const ctx = usePhoneInput();
return (
<InputGroupAddon
align="inline-start"
data-slot="phone-input-country-select"
className="cursor-pointer"
>
<Popover open={ctx.open} onOpenChange={ctx.setOpen}>
<PopoverTrigger asChild>
<InputGroupButton
type="button"
size="sm"
role="combobox"
aria-expanded={ctx.open}
aria-haspopup="listbox"
aria-label={`Country code, currently ${ctx.country.name} ${ctx.country.dialCode}`}
disabled={ctx.disabled}
data-state={ctx.open ? "open" : "closed"}
className={cn("gap-1.5 font-mono text-xs", className)}
{...props}
>
<span className="font-medium text-foreground">
{ctx.country.iso2}
</span>
<span className="text-muted-foreground">
{ctx.country.dialCode}
</span>
<ChevronDown
className={cn(
"size-3 text-muted-foreground transition-transform duration-150",
ctx.open && "rotate-180",
)}
/>
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="w-64 p-0"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
>
<Command loop>
<CommandInput placeholder="Search countries…" />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((c) => (
<CommandItem
key={c.iso2}
value={`${c.name} ${c.iso2} ${c.dialCode}`}
onSelect={() => {
ctx.setCountry(c);
ctx.setOpen(false);
}}
className="justify-between"
>
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-xs font-medium">
{c.iso2}
</span>
<span className="min-w-0 truncate">{c.name}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="font-mono text-xs text-muted-foreground">
{c.dialCode}
</span>
{ctx.country.iso2 === c.iso2 && (
<Check
className="size-3.5 text-foreground"
strokeWidth={3}
/>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</InputGroupAddon>
);
}
type PhoneInputFieldProps = Omit<
React.ComponentProps<"input">,
"type" | "value" | "defaultValue" | "onChange" | "id"
>;
function PhoneInputField({
placeholder = "Phone number",
onBlur,
inputMode = "tel",
className,
...props
}: PhoneInputFieldProps) {
const ctx = usePhoneInput();
return (
<InputGroupInput
id={ctx.id}
type="tel"
inputMode={inputMode}
autoComplete="tel-national"
dir="ltr"
value={ctx.national}
placeholder={placeholder}
disabled={ctx.disabled}
data-slot="phone-input-field"
className={cn("rtl:text-right", className)}
onChange={(e) => {
const cleaned = e.target.value.replace(/[^\d\s]/g, "");
ctx.setNational(cleaned);
}}
onBlur={(e) => {
onBlur?.(e);
const normalized = ctx.national.replace(/\s+/g, " ").trim();
if (normalized !== ctx.national) ctx.setNational(normalized);
}}
{...props}
/>
);
}
export { PhoneInput, PhoneInputCountrySelect, PhoneInputField };
Dependencies
shadcn registry
input-grouppopovercommand
npm
lucide-react