DEV Community

Cover image for Let's create international phone number input with React, Tailwind CSS and Headless UI
Dima Vyshniakov
Dima Vyshniakov

Posted on

Let's create international phone number input with React, Tailwind CSS and Headless UI

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.

Phone input demo

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.

Component breakdown

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;
Enter fullscreen mode Exit fullscreen mode

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);
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Phone input masking

Phone number masking demo

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],
);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 };
};

Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Handle country selection

Country selection demo

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,
});
Enter fullscreen mode Exit fullscreen mode

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 };
};

Enter fullscreen mode Exit fullscreen mode

Filter country list

Country list filter demo

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,
});
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

Working demo

Here is a full demo of the international phone input component.

Happy coding!

Top comments (0)