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.jsonUsage
import { PhoneInput, PhoneInputCountrySelect, PhoneInputField } from "@/components/phone-input"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<PhoneInput />
+ native element props| Prop | Type | Default |
|---|---|---|
id | string | none |
value | string | none |
defaultValue | string | none |
onValueChange | ((e164: string) => void) | none |
defaultCountry | string | 'US' |
disabled | boolean | none |
children | React.ReactNode | none |
<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/bases/radix/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/registry/hirael/bases/radix/ui/command';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@/registry/hirael/bases/radix/ui/input-group';
export interface 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;
const findCountry = (iso2: string): Country | undefined => {
return COUNTRIES.find((c) => c.iso2 === iso2.toUpperCase());
};
const digitsOnly = (input: string): string => {
return input.replace(/\D/g, '');
};
const 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) };
};
interface 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);
const usePhoneInput = () => {
const ctx = React.useContext(PhoneInputContext);
if (!ctx) {
throw new Error('PhoneInput compound parts must be used inside <PhoneInput>');
}
return ctx;
};
export interface PhoneInputProps extends Omit<React.ComponentProps<'div'>, 'children'> {
id?: string;
value?: string;
defaultValue?: string;
onValueChange?: (e164: string) => void;
defaultCountry?: string;
disabled?: boolean;
children?: React.ReactNode;
}
const 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'>;
const 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'>;
const 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