Back in the day, users had to shout a phone number or address to a phone company employee to get connected. Though today, this approach become again popular among automated call management systems, phone inputs made great progress, coming from round plastic deals with 10 digits to modern on-screen interfaces.
We are going to make one of them today. This input will be capable of:
International country code integration. We will provide a clear and easily accessible country code selector, which is essential for global user bases. This feature ensures accurate number formatting.
Input masking. Applying input masks simplifies the entry process by guiding users through the correct number format. This reduces errors and improves readability.
Accessible country list navigation. We are going to provide users a choice from multiple countries. So they should be able to navigate and filter the list effortlessly.
Create a phone field set
Here is a breakdown of the whole international phone number fieldset component.
CountryCodeInput
provides a list-box dropdown for selecting country codes. PhoneNumberInput
takes care of the phone number entry with format validation. Field
from Headless UI provides the form field structure, but we can't use the library's Label
component, so we have to implement basic HTMLLabelElement
.
import { Field } from '@headlessui/react';
import { countryList } from './countryList.ts';
import { CountryCodeInput } from './CountryCodeInput';
import { PhoneNumberInput } from './PhoneNumberInput';
const App = () => {
// ...
return (
<Field className="flex flex-col gap-2">
<label className="cursor-pointer text-sm/6 font-medium" htmlFor={id}>
Phone number:
</label>
<div className="flex gap-3">
<CountryCodeInput
countryList={countryList}
value={countryCode}
onChange={handleCodeChange}
/>
<PhoneNumberInput
id={id}
onChange={handlePhoneChange}
value={phoneNumber}
/>
</div>
</Field>
);
};
export default App;
The main component (src/App.tsx
) maintains two state variables:
phoneNumber
which stores the user's entered phone number
countryCode
which tracks the selected country code (defaulting to "+1"
).
import { useCallback, useState } from 'react';
import { countryList, CountryConfig } from './countryList.ts';
// phone number logic
const [phoneNumber, setPhoneNumber] = useState('');
const handlePhoneChange = useCallback((value: string) => {
setPhoneNumber(value);
}, []);
// country code logic
const [countryCode, setCountryCode] = useState<CountryConfig['code']>('+1');
const handleCodeChange = useCallback((nextCode?: CountryConfig['code']) => {
if (nextCode) {
setCountryCode(nextCode);
}
}, []);
Country codes configuration
Inside src/countryList.ts
we'll define a data required to give the user a choice between multiple country phone codes and formats.
CountryConfig
type defines the structure for each country entry with:
-
flag
: Unicode flag emoji representation (e.g., “🇫🇷” for France). -
code
: Country dialing code (e.g., “+33” for France). A template literal type that ensures all country codes start with a + symbol, followed by numbers or other characters. -
name
: Full country name. -
mask
: Optional formatting pattern for phone numbers. More on this later.
type Code = `+${number | string}`;
export type CountryConfig = {
flag: string;
code: Code;
name: string;
mask?: string;
};
Phone number input
The main input of the phone fieldset is located at src/PhoneNumberInput/PhoneNumberInput.tsx
. The component accepts four props: id
: a unique identifier for the input element; mask
: an optional string defining the input mask; onChange
: a callback function to handle changes in the input value; value
is the current value of the input.
We will use the following Tailwind CSS classes: tabular-nums
: consistent number character width, focus:outline-none data-[focus]:outline-2 ...
: reset default outline and add custom one.
import { ChangeEvent, FC, useCallback } from 'react';
import classNames from 'classnames';
import { Input } from '@headlessui/react';
import { useMask } from './useMask.ts';
export type Props = {
id: string;
mask?: string;
onChange: (value: string) => void;
value: string;
};
export const PhoneNumberInput: FC<Props> = ({
id,
mask = '______________',
onChange,
value: valueProp,
}) => {
const { options, hasEmptyMask, inputRef } = useMask({ mask });
return (
<Input
placeholder={hasEmptyMask ? 'Phone number' : mask}
ref={inputRef}
id={id}
className={classNames(
'w-36 rounded-md bg-stone-100 px-2 py-1.5 text-sm/6 tabular-nums text-black',
'focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-black/25',
)}
type="tel"
onChange={handleChange}
value={value}
/>
);
};
Phone input masking
Input masking guides the user to enter their phone number in a consistent and correct format. As the user types, the input field automatically inserts characters like parentheses, hyphens, and spaces at the appropriate positions.
Inside PhoneNumberInput
we'll use the format
utility from the @react-input/mask library to clean up output value from masking symbols.
import { format } from '@react-input/mask';
// ...
const value = format(valueProp, options);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange(format(event.target.value, options));
},
[onChange, options],
);
The src/PhoneNumberInput/useMask.ts
custom hook is used to implement the input mask logic. It returns options
for the mask, a boolean hasEmptyMask
to check if the mask is empty, and inputRef
to reference the input element.
// src/PhoneNumberInput/PhoneNumberInput.tsx
const { options, hasEmptyMask, inputRef } = useMask({ mask });
The hook accepts a single prop (mask
), which is a string defining the desired input mask pattern. This pattern specifies how the input should be formatted, with underscores (_
) representing digit placeholders.
Within the hook, the useMemo
is used to create the mask options. This ensures that they are only recalculated when the mask
prop changes, optimizing performance. The replacement object within the options specifies that underscores should be replaced by digits /\d/
.
The useMaskVanilla
function from the @react-input/mask
library is called with the mask options to create a reference for the input element.
The hook also includes a check to determine if the provided mask is empty. This is done by splitting the mask string into individual characters and checking if all characters are underscores. The result is stored in the hasEmptyMask
variable.
import { useMemo } from 'react';
import { useMask as useMaskVanilla, } from '@react-input/mask';
export type Props = {
mask: string;
};
export const useMask = ({ mask }: Props) => {
const options = useMemo(
() => ({
mask,
replacement: { _: /\d/ },
}),
[mask],
);
const inputRef = useMaskVanilla(options);
const hasEmptyMask = mask.split('').every((char) => {
return char === '_';
});
return { options, inputRef, hasEmptyMask };
};
Country code selection input
A phone country code, also known as an international subscriber dialing (ISD) code, is a numerical prefix that precedes a phone number. Its primary function is to route calls and messages to the correct country or geographical region.
src/CountryCodeInput/CountryCodeInput.tsx
accepts three props: countryList
, value
, and onChange
. The countryList
is an array of country configurations, value
is the currently selected country code (+351
), and onChange
is a callback function to handle changes in the selected country code.
export type Props = {
countryList: CountryConfig[];
value: CountryConfig['code'];
onChange: (value?: CountryConfig['code']) => void;
};
CountryCodeInput
maintains an internal state for the country list, which is initialized with the countryList
property and updated whenever the latter changes.
const [countryList, setCountryList] = useState(countryListProp);
useEffect(() => {
setCountryList(countryListProp);
}, [countryListProp]);
We'll implement country code input as a combination of Listbox and Input components from Headless UI.
The Listbox
component is used to create the dropdown menu. The ListboxButton
displays the selected country code and flag, and the ListboxOptions
contains the list of country options. This is what the user sees initially.
The Input
component is used to filter the country list when there are more than five countries.
We assigned the Tailwind group class to ListboxOption
to display a checkmark to indicate the current choice.
<div className="w-24">
<Listbox value={selected} onChange={handleSelect}>
<ListboxButton
className={classNames(
'relative w-full rounded-md bg-stone-100 px-2 py-1.5 text-sm/6 text-black',
'flex items-center gap-1',
'focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-black/25',
)}
>
<span>{selectedFlag}</span>
<span className="ml-auto grow-0">{selected}</span>
<Icon className="group pointer-events-none size-3.5 shrink-0 text-black/60" name="caret-down" />
</ListboxButton>
<ListboxOptions
anchor={ANCHOR_PROP}
transition
className={classNames(
'w-56 rounded-md border border-stone-300 bg-stone-100 focus-visible:outline-none',
'flex flex-col',
'origin-top transition duration-200 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'shadow-md shadow-black/20',
)}
>
{countryListProp.length > 5 && (
<div className=" border-b-2 border-b-black/10 p-2">
<Input
value={filter}
onChange={setFilter}
placeholder="Country name"
className={classNames(
'max-w-full rounded-full bg-stone-200 px-4 py-1 text-sm/6 text-black ',
'focus:bg-white focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-black/25',
)}
/>
</div>
)}
<div className="max-h-48 grow overflow-auto">
{countryList.map(({ code, flag, name }) => (
<ListboxOption
key={code}
value={code}
className="group flex cursor-pointer select-none items-center gap-1 p-1.5 data-[focus]:bg-black/10"
>
<div className="size-4">
<Icon className="invisible size-4 text-black group-data-[selected]:visible" name="check" />
</div>
{flag}
<div className="w-9 shrink-0 text-right text-sm/6 tabular-nums text-black">
{code}
</div>
<div className="truncate text-sm/6 text-black/65 group-data-[hover]:text-black">
{name}
</div>
</ListboxOption>
))}
</div>
</ListboxOptions>
</Listbox>
</div>
Handle country selection
src/CountryCodeInput/useCountrySelect.ts
custom hook manages the selection logic for the country list. It provides the selectedFlag
, handleSelect
, and selected
values to handle the selection and display of the selected country.
useCountrySelect
accepts the same props as a parent CountryCodeInput
. NB! We use countryList
property value, not the internal state of the same name. This is needed to be compatible with filter functionality.
// src/CountryCodeInput/CountryCodeInput.tsx
const { selectedFlag, handleSelect, selected } = useCountrySelect({
onChange,
countryListProp,
value,
});
useCountrySelect
maintains an internal state selected to keep track of the currently selected country code. This state is initialized with the value prop and updated whenever the value prop changes.
handleSelect
function updates the selected state and calls the onChange
callback with the newly selected country code.
selectedFlag
variable is computed using the useMemo
hook. It finds the flag of the currently selected country by searching the countryListProp
array for the country with the matching code.
Finally, the hook returns these values to be used by the parent component to manage the selection and display of the selected country.
import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { CountryConfig } from '../countryList.ts';
export type Props = {
countryListProp: CountryConfig[];
value: CountryConfig['code'];
onChange: (value?: CountryConfig['code']) => void;
};
export const useCountrySelect = ({
value,
onChange,
countryListProp,
}: Props) => {
const [selected, setSelected] = useState(value);
useEffect(() => {
setSelected(value);
}, [value]);
const handleSelect = useCallback(
(selectedCode: CountryConfig['code']) => {
onChange(selectedCode);
setSelected(selectedCode);
},
[onChange],
);
const selectedFlag = useMemo(
() => countryListProp.find(({ code }) => code === selected)?.flag,
[countryListProp, selected],
);
return { selected, handleSelect, selectedFlag };
};
Filter country list
src/CountryCodeInput/useCountryFilter.ts
custom hook is used to filter the country list based on user input. It provides a filter
state and a setFilter
function to update the filter value.
useCountryFilter
accepts two props: countryListProp
(same as above) and setCountryList
: a function to update the filtered country list.
// src/CountryCodeInput/CountryCodeInput.tsx
const { filter, setFilter } = useCountryFilter({
setCountryList,
countryListProp,
});
useCountryFilter
maintains an internal state filter
to keep track of the current filter value. This state is initialized as an empty string.
setFilter
function updates the filter
state and filters the countryListProp
based on the user input. If the input is not empty, it filters the list to include only countries whose names contain the input value (case-insensitive). If the input is empty, it resets the list to the original countryListProp
.
import { ChangeEvent, useCallback, useState, Dispatch, SetStateAction } from 'react';
import { CountryConfig } from '../countryList.ts';
export type Props = {
countryListProp: CountryConfig[];
setCountryList: Dispatch<SetStateAction<CountryConfig[]>>;
}
export const useCountryFilter = ({setCountryList, countryListProp}: Props) => {
const [filter, setFilterState] = useState('');
const setFilter = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setFilterState(event.target.value);
const nextCountries = countryListProp.filter(({ name }) =>
name.toLowerCase().includes(event.target.value.toLowerCase()),
);
if (event.target.value !== '') {
setCountryList(nextCountries);
} else {
setCountryList(countryListProp);
}
},
[countryListProp, setCountryList],
);
return {filter, setFilter}
}
Finally, we add this block to src/CountryCodeInput/CountryCodeInput.tsx
to handle empty filter results.
{countryList.length === 0 && (
<div className="py-1.5 text-center text-sm/6 text-black/65">
No results.
</div>
)}
Working demo
Here is a full demo of the international phone input component.
Happy coding!
Top comments (0)