An ISO 3166 compliant phone input component.
UK: 00447700000000
NO: 004740000000
>_
The PhoneInput
component is a compliant phone input with international call prefixes. It's built using the libphonenumber-js
package for parsing and validating phone numbers.
The PhoneInput
component is built without shadcn/ui dependencies, however is styled to fit seamlessly with the rest of the component library. You can fully customize the component to your needs.
CountryDropdown
The PhoneInput
component can be used in conjunction with the CountryDropdown
component to provide a better user experience by allowing the user to select the country before entering the phone number.
Install the PhoneInput
dependencies using your preferred package manager:
# Install dependencies
pnpm add react-circle-flags country-data-list libphonenumber-js
Copy the component below to components/ui/phone-input.tsx
.
"use client";
import { useState, forwardRef, useEffect } from "react";
import parsePhoneNumber, { isValidPhoneNumber } from "libphonenumber-js";
import { CircleFlag } from "react-circle-flags";
import { lookup } from "country-data-list";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { GlobeIcon } from "lucide-react";
export const phoneSchema = z.string().refine((value) => {
try {
return isValidPhoneNumber(value);
} catch {
return false;
}
}, "Invalid phone number");
export type CountryData = {
alpha2: string;
alpha3: string;
countryCallingCodes: string[];
currencies: string[];
emoji?: string;
ioc: string;
languages: string[];
name: string;
status: string;
};
interface PhoneInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
onCountryChange?: (data: CountryData | undefined) => void;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
defaultCountry?: string;
className?: string;
inline?: boolean;
}
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(
(
{
className,
onCountryChange,
onChange,
value,
placeholder,
defaultCountry,
inline = false,
...props
},
ref
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [countryData, setCountryData] = useState<CountryData | undefined>();
const [displayFlag, setDisplayFlag] = useState<string>("");
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
if (defaultCountry) {
const newCountryData = lookup.countries({
alpha2: defaultCountry.toLowerCase(),
})[0];
setCountryData(newCountryData);
setDisplayFlag(defaultCountry.toLowerCase());
if (
!hasInitialized &&
newCountryData?.countryCallingCodes?.[0] &&
!value
) {
const syntheticEvent = {
target: {
value: newCountryData.countryCallingCodes[0],
},
} as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
setHasInitialized(true);
}
}
}, [defaultCountry, onChange, value, hasInitialized]);
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// Ensure the value starts with "+"
if (!newValue.startsWith("+")) {
// Replace "00" at the start with "+" if present
if (newValue.startsWith("00")) {
newValue = "+" + newValue.slice(2);
} else {
// Otherwise just add "+" at the start
newValue = "+" + newValue;
}
}
try {
const parsed = parsePhoneNumber(newValue);
console.log("Phone number details:", {
isPossible: parsed?.isPossible(),
isValid: parsed?.isValid(),
country: parsed?.country,
nationalNumber: parsed?.nationalNumber,
formatNational: parsed?.formatNational(),
formatInternational: parsed?.formatInternational(),
getType: parsed?.getType(),
countryCallingCode: parsed?.countryCallingCode,
getURI: parsed?.getURI(),
parsed: parsed,
});
if (parsed && parsed.country) {
// Update flag first
const countryCode = parsed.country;
console.log("Setting flag to:", countryCode.toLowerCase());
// Force immediate update
setDisplayFlag(""); // Clear first
setTimeout(() => {
setDisplayFlag(countryCode.toLowerCase()); // Then set new value
}, 0);
// Update other state
const countryInfo = lookup.countries({ alpha2: countryCode })[0];
setCountryData(countryInfo);
onCountryChange?.(countryInfo);
// Update input value
const syntheticEvent = {
...e,
target: {
...e.target,
value: parsed.number,
},
} as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
} else {
onChange?.(e);
setDisplayFlag("");
setCountryData(undefined);
onCountryChange?.(undefined);
}
} catch (error) {
console.error("Error parsing phone number:", error);
onChange?.(e);
setDisplayFlag("");
setCountryData(undefined);
onCountryChange?.(undefined);
}
};
const inputClasses = cn(
"flex items-center gap-2 relative bg-transparent transition-colors text-base rounded-md border border-input pl-3 h-9 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed md:text-sm has-[input:focus]:outline-none has-[input:focus]:ring-1 has-[input:focus]:ring-ring [interpolate-size:allow-keywords]",
inline && "rounded-l-none w-full",
className
);
return (
<div className={inputClasses}>
{!inline && (
<div className="w-4 h-4 rounded-full shrink-0">
{displayFlag ? (
<CircleFlag countryCode={displayFlag} height={16} />
) : (
<GlobeIcon size={16} />
)}
</div>
)}
<input
ref={ref}
value={value}
onChange={handlePhoneChange}
placeholder={placeholder || "Enter number"}
type="tel"
autoComplete="tel"
name="phone"
className={cn(
"flex w-full border-none bg-transparent text-base transition-colors placeholder:text-muted-foreground outline-none h-9 py-1 p-0 leading-none md:text-sm [interpolate-size:allow-keywords]",
className
)}
{...props}
/>
</div>
);
}
);
PhoneInput.displayName = "PhoneInput";
Connected values allow you to connect both the CountryDropdown
and PhoneInput
components.
UK: 00447700000000
NO: 004740000000
>_
A standalone PhoneInput
with shadcn/ui forms.
UK: 00447700000000
NO: 004740000000
>_