Phone Input
An accessible phone input component with country code dropdown and international phone number support.
import * as React from "react";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
export function PhoneInputDemo() {
return (
<PhoneInput defaultValue="5551234" showFlag={true}>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
);
}Installation
CLI
npx shadcn@latest add @diceui/phone-inputManual
Install the following dependencies:
npm install @radix-ui/react-slot lucide-reactCopy and paste the refs composition utilities into your lib/compose-refs.ts file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the visually hidden input component into your components/visually-hidden-input.tsx file.
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };Copy and paste the following hooks into your hooks directory.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "PhoneInput";
const COUNTRY_SELECT_NAME = "PhoneInputCountrySelect";
const FIELD_NAME = "PhoneInputField";
/**
* @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js
* @format [iso2, dialCode]
*/
const COUNTRY_DATA: [string, string][] = [
["af", "93"],
["ax", "358"],
["al", "355"],
["dz", "213"],
["as", "1684"],
["ad", "376"],
["ao", "244"],
["ai", "1264"],
["ag", "1268"],
["ar", "54"],
["am", "374"],
["aw", "297"],
["au", "61"],
["at", "43"],
["az", "994"],
["bs", "1242"],
["bh", "973"],
["bd", "880"],
["bb", "1246"],
["by", "375"],
["be", "32"],
["bz", "501"],
["bj", "229"],
["bm", "1441"],
["bt", "975"],
["bo", "591"],
["ba", "387"],
["bw", "267"],
["br", "55"],
["io", "246"],
["vg", "1284"],
["bn", "673"],
["bg", "359"],
["bf", "226"],
["bi", "257"],
["kh", "855"],
["cm", "237"],
["ca", "1"],
["cv", "238"],
["bq", "599"],
["ky", "1345"],
["cf", "236"],
["td", "235"],
["cl", "56"],
["cn", "86"],
["co", "57"],
["km", "269"],
["cd", "243"],
["cg", "242"],
["ck", "682"],
["cr", "506"],
["ci", "225"],
["hr", "385"],
["cu", "53"],
["cw", "599"],
["cy", "357"],
["cz", "420"],
["dk", "45"],
["dj", "253"],
["dm", "1767"],
["do", "1"],
["ec", "593"],
["eg", "20"],
["sv", "503"],
["gq", "240"],
["er", "291"],
["ee", "372"],
["et", "251"],
["fk", "500"],
["fo", "298"],
["fj", "679"],
["fi", "358"],
["fr", "33"],
["gf", "594"],
["pf", "689"],
["ga", "241"],
["gm", "220"],
["ge", "995"],
["de", "49"],
["gh", "233"],
["gi", "350"],
["gr", "30"],
["gl", "299"],
["gd", "1473"],
["gp", "590"],
["gu", "1671"],
["gt", "502"],
["gg", "44"],
["gn", "224"],
["gw", "245"],
["gy", "592"],
["ht", "509"],
["hn", "504"],
["hk", "852"],
["hu", "36"],
["is", "354"],
["in", "91"],
["id", "62"],
["ir", "98"],
["iq", "964"],
["ie", "353"],
["im", "44"],
["il", "972"],
["it", "39"],
["jm", "1876"],
["jp", "81"],
["je", "44"],
["jo", "962"],
["kz", "7"],
["ke", "254"],
["ki", "686"],
["xk", "383"],
["kw", "965"],
["kg", "996"],
["la", "856"],
["lv", "371"],
["lb", "961"],
["ls", "266"],
["lr", "231"],
["ly", "218"],
["li", "423"],
["lt", "370"],
["lu", "352"],
["mo", "853"],
["mk", "389"],
["mg", "261"],
["mw", "265"],
["my", "60"],
["mv", "960"],
["ml", "223"],
["mt", "356"],
["mh", "692"],
["mq", "596"],
["mr", "222"],
["mu", "230"],
["mx", "52"],
["fm", "691"],
["md", "373"],
["mc", "377"],
["mn", "976"],
["me", "382"],
["ms", "1664"],
["ma", "212"],
["mz", "258"],
["mm", "95"],
["na", "264"],
["nr", "674"],
["np", "977"],
["nl", "31"],
["nc", "687"],
["nz", "64"],
["ni", "505"],
["ne", "227"],
["ng", "234"],
["nu", "683"],
["nf", "672"],
["kp", "850"],
["mp", "1670"],
["no", "47"],
["om", "968"],
["pk", "92"],
["pw", "680"],
["ps", "970"],
["pa", "507"],
["pg", "675"],
["py", "595"],
["pe", "51"],
["ph", "63"],
["pl", "48"],
["pt", "351"],
["pr", "1"],
["qa", "974"],
["re", "262"],
["ro", "40"],
["ru", "7"],
["rw", "250"],
["bl", "590"],
["sh", "290"],
["kn", "1869"],
["lc", "1758"],
["mf", "590"],
["pm", "508"],
["vc", "1784"],
["ws", "685"],
["sm", "378"],
["st", "239"],
["sa", "966"],
["sn", "221"],
["rs", "381"],
["sc", "248"],
["sl", "232"],
["sg", "65"],
["sx", "1721"],
["sk", "421"],
["si", "386"],
["sb", "677"],
["so", "252"],
["za", "27"],
["kr", "82"],
["ss", "211"],
["es", "34"],
["lk", "94"],
["sd", "249"],
["sr", "597"],
["sz", "268"],
["se", "46"],
["ch", "41"],
["sy", "963"],
["tw", "886"],
["tj", "992"],
["tz", "255"],
["th", "66"],
["tl", "670"],
["tg", "228"],
["tk", "690"],
["to", "676"],
["tt", "1868"],
["tn", "216"],
["tr", "90"],
["tm", "993"],
["tc", "1649"],
["tv", "688"],
["vi", "1340"],
["ug", "256"],
["ua", "380"],
["ae", "971"],
["gb", "44"],
["us", "1"],
["uy", "598"],
["uz", "998"],
["vu", "678"],
["va", "39"],
["ve", "58"],
["vn", "84"],
["wf", "681"],
["eh", "212"],
["ye", "967"],
["zm", "260"],
["zw", "263"],
];
interface Country {
code: string;
name: string;
dialCode: string;
flag?: string;
}
function getCountryName(countryCode: string, locale = "en"): string {
try {
const regionNames = new Intl.DisplayNames([locale], { type: "region" });
return regionNames.of(countryCode) ?? countryCode;
} catch {
return countryCode;
}
}
function getFlagEmoji(countryCode: string): string {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
function getCountries(): Country[] {
return COUNTRY_DATA.map(([iso2, dialCode]): Country => {
const code = iso2.toUpperCase();
return {
code,
name: getCountryName(code),
dialCode: `+${dialCode}`,
flag: getFlagEmoji(code),
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
function getCountryFromLocale(
countries: Country[],
locale?: string,
): string | undefined {
if (typeof window === "undefined" && !locale) {
return undefined;
}
const userLocale =
locale || (typeof navigator !== "undefined" ? navigator.language : "");
if (!userLocale) return undefined;
const regionCode = userLocale.split("-")[1]?.toUpperCase();
if (regionCode && countries.some((c) => c.code === regionCode)) {
return regionCode;
}
return undefined;
}
type RootElement = React.ComponentRef<typeof PhoneInput>;
interface StoreState {
value: string;
country: string;
isLoading: boolean;
open: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface PhoneInputContextValue {
rootId: string;
countries: Country[];
placeholder: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag: boolean;
showDialCode: boolean;
inputRef: React.RefObject<HTMLInputElement | null>;
}
const PhoneInputContext = React.createContext<PhoneInputContextValue | null>(
null,
);
function usePhoneInputContext(consumerName: string) {
const context = React.useContext(PhoneInputContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface PhoneInputProps extends React.ComponentProps<"div"> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
defaultCountry?: string;
country?: string;
onCountryChange?: (country: string) => void;
countries?: Country[];
locale?: string;
autoDetect?: boolean;
name?: string;
placeholder?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
invalid?: boolean;
showFlag?: boolean;
showDialCode?: boolean;
}
function PhoneInput(props: PhoneInputProps) {
const {
value: valueProp,
defaultValue,
defaultCountry,
country: countryProp,
onValueChange,
onCountryChange,
countries = getCountries(),
locale,
autoDetect = true,
name,
placeholder = "Enter phone number",
asChild,
disabled,
required,
readOnly,
invalid,
showFlag = true,
showDialCode = false,
className,
children,
id,
ref,
...rootProps
} = props;
const instanceId = React.useId();
const rootId = id ?? instanceId;
const inputRef = React.useRef<HTMLInputElement>(null);
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => {
const initialCountry = countryProp ?? defaultCountry;
const shouldAutoDetect = autoDetect && !initialCountry;
return {
value: valueProp ?? defaultValue ?? "",
country: initialCountry ?? "",
isLoading: shouldAutoDetect,
open: false,
};
});
const propsRef = useAsRef({
onValueChange,
onCountryChange,
});
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "value" && typeof value === "string") {
stateRef.current.value = value;
propsRef.current.onValueChange?.(value);
} else if (key === "country" && typeof value === "string") {
stateRef.current.country = value;
propsRef.current.onCountryChange?.(value);
} else {
stateRef.current[key] = value;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
const value = useStore((state) => state.value, store);
const country = useStore((state) => state.country, store);
const isLoading = useStore((state) => state.isLoading, store);
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run once on mount to detect locale, not on every state change
React.useEffect(() => {
if (!isLoading) return;
const detectedCountry =
getCountryFromLocale(countries, locale) || countries[0]?.code;
if (detectedCountry) {
store.setState("country", detectedCountry);
}
store.setState("isLoading", false);
}, []);
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp]);
useIsomorphicLayoutEffect(() => {
if (countryProp !== undefined) {
store.setState("country", countryProp);
}
}, [countryProp]);
const contextValue = React.useMemo<PhoneInputContextValue>(
() => ({
rootId,
countries,
placeholder,
disabled,
readOnly,
required,
invalid,
showFlag,
showDialCode,
inputRef,
}),
[
rootId,
countries,
placeholder,
disabled,
required,
readOnly,
invalid,
showFlag,
showDialCode,
],
);
const RootPrimitive = asChild ? Slot : "div";
const currentCountry = countries.find((c) => c.code === country);
const fullValue = currentCountry
? `${currentCountry.dialCode}${value}`
: value;
return (
<StoreContext.Provider value={store}>
<PhoneInputContext.Provider value={contextValue}>
<RootPrimitive
role="group"
data-slot="phone-input"
data-disabled={disabled ? "" : undefined}
data-invalid={invalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
{...rootProps}
id={id}
ref={composedRef}
className={cn(
"relative flex h-10 w-full items-center overflow-hidden rounded-md border border-input bg-background transition-colors has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
>
{children}
</RootPrimitive>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={fullValue}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</PhoneInputContext.Provider>
</StoreContext.Provider>
);
}
interface PhoneInputCountrySelectProps
extends React.ComponentProps<typeof Popover>,
Pick<
React.ComponentProps<typeof PopoverTrigger>,
"disabled" | "className"
> {}
function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) {
const {
disabled: disabledProp,
className,
children,
onOpenChange: onOpenChangeProp,
...popoverProps
} = props;
const { countries, inputRef, disabled, showDialCode, showFlag } =
usePhoneInputContext(COUNTRY_SELECT_NAME);
const store = useStoreContext(COUNTRY_SELECT_NAME);
const country = useStore((state) => state.country);
const isLoading = useStore((state) => state.isLoading);
const open = useStore((state) => state.open);
const isDisabled = disabledProp || disabled;
const countryContext = countries.find((c) => c.code === country);
const onOpenChange = React.useCallback(
(open: boolean) => {
store.setState("open", open);
onOpenChangeProp?.(open);
},
[store, onOpenChangeProp],
);
return (
<Popover open={open} onOpenChange={onOpenChange} {...popoverProps}>
<PopoverTrigger
data-slot="phone-input-country-select"
disabled={isDisabled}
className={cn(
"flex h-full shrink-0 items-center gap-2 rounded-l-md border-input border-r bg-transparent px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
>
{isLoading || !countryContext ? (
<div
className={cn(
"h-4 rounded",
isLoading ? "animate-pulse bg-muted" : "bg-muted/50",
showFlag && showDialCode
? "w-17"
: showDialCode
? "w-10"
: showFlag
? "w-4.5"
: "w-8",
)}
/>
) : (
<>
{showFlag && countryContext.flag && (
<span className="text-lg leading-none">
{countryContext.flag}
</span>
)}
{showDialCode && countryContext.dialCode && (
<span className="font-medium">{countryContext.dialCode}</span>
)}
</>
)}
<ChevronDown className="size-4 opacity-50" />
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem
key={c.code}
value={`${c.name} ${c.dialCode} ${c.code}`}
onSelect={() => {
store.setState("country", c.code);
store.setState("open", false);
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}}
>
{showFlag && c.flag && (
<span className="text-lg">{c.flag}</span>
)}
<span className="flex-1">{c.name}</span>
<span className="text-muted-foreground">{c.dialCode}</span>
<Check
className={cn(
"size-4",
country === c.code ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function PhoneInputField(props: React.ComponentProps<"input">) {
const {
onChange: onChangeProp,
className,
disabled: disabledProp,
readOnly: readOnlyProp,
required: requiredProp,
ref,
...inputProps
} = props;
const { inputRef, disabled, invalid, readOnly, required, placeholder } =
usePhoneInputContext(FIELD_NAME);
const store = useStoreContext(FIELD_NAME);
const value = useStore((state) => state.value);
const composedRef = useComposedRefs(ref, inputRef);
const onChangeRef = useAsRef(onChangeProp);
const isDisabled = disabledProp || disabled;
const isReadOnly = readOnlyProp || readOnly;
const isRequired = requiredProp || required;
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled || isReadOnly) return;
onChangeRef.current?.(event);
if (event.defaultPrevented) return;
const sanitized = event.target.value.replace(/[^\d\s()-]/g, "");
store.setState("value", sanitized);
},
[store, onChangeRef, isDisabled, isReadOnly],
);
return (
<Input
type="tel"
inputMode="tel"
aria-required={isRequired}
aria-invalid={invalid}
data-slot="phone-input-field"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...inputProps}
ref={composedRef}
className={cn(
"h-full flex-1 rounded-r-md rounded-l-none border-0 bg-transparent shadow-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:bg-transparent aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-transparent dark:aria-invalid:ring-destructive/40 dark:disabled:bg-transparent",
className,
)}
placeholder={placeholder}
value={value}
onChange={onChange}
/>
);
}
export {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
//
useStore as usePhoneInput,
//
type PhoneInputProps,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
return (
<PhoneInput>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
)Locale Detection
The phone input automatically detects the user's country based on their browser locale. You can override this behavior by providing a defaultCountry or locale prop.
// Automatically detects from browser locale (e.g., en-US → US)
<PhoneInput>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
// Override with a specific country
<PhoneInput defaultCountry="GB">
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
// Use a specific locale for detection
<PhoneInput locale="en-GB">
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
// Disable auto-detection, force users to select
<PhoneInput autoDetect={false}>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>If no country can be detected from the locale, it defaults to the first country in the countries list.
Examples
Custom Countries
Provide a custom list of countries to display in the dropdown.
import type { PhoneInputProps } from "@/components/ui/phone-input";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
const NORTH_AMERICAN_COUNTRIES: PhoneInputProps["countries"] = [
{ code: "US", name: "United States", dialCode: "+1", flag: "🇺🇸" },
{ code: "CA", name: "Canada", dialCode: "+1", flag: "🇨🇦" },
{ code: "MX", name: "Mexico", dialCode: "+52", flag: "🇲🇽" },
{ code: "BR", name: "Brazil", dialCode: "+55", flag: "🇧🇷" },
];
export function PhoneInputCustomCountriesDemo() {
return (
<PhoneInput
defaultValue="5551234"
defaultCountry="US"
countries={NORTH_AMERICAN_COUNTRIES}
>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
);
}No Auto-Detection
Disable automatic country detection to force users to explicitly select a country.
import * as React from "react";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
export function PhoneInputNoAutoDetectDemo() {
return (
<PhoneInput autoDetect={false}>
<PhoneInputCountrySelect />
<PhoneInputField placeholder="Select country first" />
</PhoneInput>
);
}With Form
Use the phone input component in a form with validation.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
PhoneInput,
PhoneInputCountrySelect,
PhoneInputField,
} from "@/components/ui/phone-input";
const FormSchema = z.object({
phone: z.string().min(1, {
message: "Phone number is required.",
}),
country: z.string().min(1, {
message: "Country is required.",
}),
});
export function PhoneInputFormDemo() {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
phone: "",
country: "US",
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
const country = data.country;
const phoneNumber = data.phone;
toast.success("Phone number submitted", {
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify({ country, phone: phoneNumber }, null, 2)}
</code>
</pre>
),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-6">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone Number</FormLabel>
<FormControl>
<PhoneInput
value={field.value}
onValueChange={field.onChange}
country={form.watch("country")}
onCountryChange={(country) =>
form.setValue("country", country)
}
required
>
<PhoneInputCountrySelect />
<PhoneInputField />
</PhoneInput>
</FormControl>
<FormDescription>
Enter your phone number with country code.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}API Reference
PhoneInput
The root container component that acts as both the wrapper and input group. Handles layout, borders, and focus states.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-disabled] | Present when the phone input is disabled |
[data-invalid] | Present when the phone input is invalid |
[data-readonly] | Present when the phone input is read-only |
[data-slot] | phone-input |
PhoneInputCountrySelect
The button component that triggers the country dropdown. Uses Popover and Command internally for the country list.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-slot] | phone-input-country-select |
PhoneInputField
The input field component for entering the phone number.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-slot] | phone-input-field |
Country Type
The country object type used throughout the component.
interface Country {
code: string; // ISO 3166-1 alpha-2 country code
name: string; // Country name
dialCode: string; // Country calling code (e.g., "+1")
flag?: string; // Optional flag emoji
}Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Tab | Moves focus to the next focusable element (country select or phone input field). |
| SpaceEnter | Opens the country dropdown when focused on the country select button. |
| Escape | Closes the country dropdown. |
| ArrowUpArrowDown | Navigate through country items when the dropdown is open. |
| HomeEnd | Jump to first or last country in the list. |
| Type to search | Filter countries by name, dial code, or country code as you type. |