Skip to content

Instantly share code, notes, and snippets.

@rhyek
Last active December 6, 2025 01:34
Show Gist options
  • Select an option

  • Save rhyek/f56fbe372455ba8618f5becd67bb1658 to your computer and use it in GitHub Desktop.

Select an option

Save rhyek/f56fbe372455ba8618f5becd67bb1658 to your computer and use it in GitHub Desktop.
Mantine v7 Phone Input with country select
import { useEffect, useRef, useState } from 'react';
import {
useCombobox,
Combobox,
Group,
CheckIcon,
ScrollArea,
InputBase,
ActionIcon,
type InputBaseProps,
type PolymorphicComponentProps,
} from '@mantine/core';
import { useUncontrolled } from '@mantine/hooks';
import { IconChevronDown } from '@tabler/icons-react';
import countries from 'i18n-iso-countries';
import es from 'i18n-iso-countries/langs/es.json';
import {
getExampleNumber,
type CountryCode,
parsePhoneNumberFromString,
getCountries,
AsYouType,
} from 'libphonenumber-js';
import examples from 'libphonenumber-js/mobile/examples';
import { IMaskInput } from 'react-imask';
countries.registerLocale(es);
function getFlagEmoji(countryCode: string) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
const libIsoCountries = countries.getNames('es', { select: 'official' });
const libPhoneNumberCountries = getCountries();
const countryOptionsDataMap = Object.fromEntries(
libPhoneNumberCountries
.map((code) => {
const name = libIsoCountries[code];
const emoji = getFlagEmoji(code);
if (!name || !emoji) return null;
return [
code,
{
code,
name,
emoji,
},
] as [
CountryCode,
{
code: CountryCode;
name: string;
emoji: string;
},
];
})
.filter((o) => !!o),
);
const countryOptionsData = Object.values(countryOptionsDataMap);
type Country = (typeof countryOptionsData)[number];
function getFormat(countryCode: CountryCode) {
const example = getExampleNumber(countryCode, examples)!.formatNational();
const mask = example.replace(/\d/g, '0');
return { example, mask };
}
function getInitialDataFromValue(
value: string | undefined,
options: {
initialCountryCode: string;
},
): {
country: Country;
format: ReturnType<typeof getFormat>;
localValue: string;
} {
const defaultValue = {
country: countryOptionsDataMap[options.initialCountryCode],
format: getFormat(options.initialCountryCode as CountryCode),
localValue: '',
};
if (!value) return defaultValue;
const phoneNumber = parsePhoneNumberFromString(value);
if (!phoneNumber) return defaultValue;
if (!phoneNumber.country) return defaultValue;
return {
country: countryOptionsDataMap[phoneNumber.country],
localValue: phoneNumber.formatNational(),
format: getFormat(phoneNumber.country),
};
}
export type PhoneInputProps = {
initialCountryCode?: string;
defaultValue?: string;
} & Omit<
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>,
'onChange' | 'defaultValue'
> & { onChange: (value: string | null) => void };
export function PhoneInput({
initialCountryCode = 'GT',
value: _value,
onChange: _onChange,
defaultValue,
...props
}: PhoneInputProps) {
const [value, onChange] = useUncontrolled({
value: _value,
defaultValue,
onChange: _onChange,
});
const initialData = useRef(
getInitialDataFromValue(value, {
initialCountryCode: initialCountryCode,
}),
);
const [country, setCountry] = useState(initialData.current.country);
const [format, setFormat] = useState(initialData.current.format);
const [localValue, setLocalValue] = useState(initialData.current.localValue);
const inputRef = useRef<HTMLInputElement>(null);
const lastNotifiedValue = useRef<string | null>(value ?? '');
useEffect(() => {
let value = '';
if (localValue.trim().length > 0) {
const asYouType = new AsYouType(country.code);
asYouType.input(localValue);
value = asYouType.getNumber()?.number ?? '';
}
if (value !== lastNotifiedValue.current) {
lastNotifiedValue.current = value;
onChange(value);
}
}, [country.code, localValue]);
useEffect(() => {
if (typeof value !== 'undefined' && value !== lastNotifiedValue.current) {
const initialData = getInitialDataFromValue(value, {
initialCountryCode,
});
lastNotifiedValue.current = value;
setCountry(initialData.country);
setFormat(initialData.format);
setLocalValue(initialData.localValue);
}
}, [value]);
const { readOnly, disabled } = props;
const leftSectionWidth = 54;
return (
<InputBase
{...props}
component={IMaskInput}
inputRef={inputRef}
leftSection={
<CountrySelect
disabled={disabled || readOnly}
country={country}
setCountry={(country) => {
setCountry(country);
setFormat(getFormat(country.code));
setLocalValue('');
if (inputRef.current) {
inputRef.current.focus();
}
}}
leftSectionWidth={leftSectionWidth}
/>
}
leftSectionWidth={leftSectionWidth}
styles={{
input: {
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`,
},
section: {
borderRight: '1px solid var(--mantine-color-default-border)',
},
}}
inputMode="numeric"
mask={format.mask}
unmask={true}
value={localValue}
onAccept={(value) => setLocalValue(value)}
/>
);
}
function CountrySelect({
country,
setCountry,
disabled,
leftSectionWidth,
}: {
country: Country;
setCountry: (country: Country) => void;
disabled: boolean | undefined;
leftSectionWidth: number;
}) {
const [search, setSearch] = useState('');
const selectedRef = useRef<HTMLDivElement>(null);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch('');
},
onDropdownOpen: () => {
combobox.focusSearchInput();
setTimeout(() => {
selectedRef.current?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
}, 0);
},
});
const options = countryOptionsData
.filter((item) =>
item.name.toLowerCase().includes(search.toLowerCase().trim()),
)
.map((item) => (
<Combobox.Option
ref={item.code === country.code ? selectedRef : undefined}
value={item.code}
key={item.code}
>
<Group gap="xs">
{item.code === country.code && <CheckIcon size={12} />}
<span>
{item.emoji} {item.name}
</span>
</Group>
</Combobox.Option>
));
useEffect(() => {
if (search) {
combobox.selectFirstOption();
}
}, [search]);
return (
<Combobox
store={combobox}
width={250}
position="bottom-start"
withArrow
onOptionSubmit={(val) => {
setCountry(countryOptionsDataMap[val]);
combobox.closeDropdown();
}}
>
<Combobox.Target withAriaAttributes={false}>
<ActionIcon
variant="transparent"
onClick={() => combobox.toggleDropdown()}
size="lg"
tabIndex={-1}
disabled={disabled}
w={leftSectionWidth}
c="dimmed"
>
<Group gap={2}>
{country.emoji}
<IconChevronDown size={14} />
</Group>
</ActionIcon>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder="Buscar país"
/>
<Combobox.Options>
<ScrollArea.Autosize mah={200} type="scroll">
{options.length > 0 ? (
options
) : (
<Combobox.Empty>No encontrado</Combobox.Empty>
)}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
@rhyek
Copy link
Author

rhyek commented Oct 12, 2024

Usage:

<PhoneInput
  label="Teléfono"
  initialCountryCode="GT"
  {...form.getInputProps('phone')}
/>

It looks like this:
image

value and onChange props use E164 formatted phone numbers.

@Vincenius
Copy link

Great work! Thanks a lot - this saved me a lot of time :)

@rhyek
Copy link
Author

rhyek commented Jan 16, 2025

Great work! Thanks a lot - this saved me a lot of time :)

You're welcome! :-)

@ugurcoskn
Copy link

Thanks a lot!

@YewoMhango
Copy link

I experienced an issue where the component would crash the app if I input 1 as the first digit when entering a US number. (I don't know if it's an invalid syntax for US numbers as I'm not American). It seemed to come from this line:

value = asYouType.getNumber()!.number;

I replaced the null assertion operator (!.) with an optional chaining operator (?.)

@rhyek
Copy link
Author

rhyek commented Jun 25, 2025

Thanks, @YewoMhango. I tried value = asYouType.getNumber()?.number ?? '' and it seems to work. Updated the gist.

@suman-eleanorcare
Copy link

Thanks a lot!

@YewoMhango
Copy link

I also noticed another issue recently where the "Maximum Update Depth Exceeded" error gets triggered if you enter a 0 as the first digit in the phone number (though it doesn't happen for all country codes). So my solution was to replace the first useEffect in PhoneInput with an event handler that gets passed to onAccept directly

@suman-eleanorcare
Copy link

suman-eleanorcare commented Oct 31, 2025 via email

@rhyek
Copy link
Author

rhyek commented Oct 31, 2025

@YewoMhango @suman-eleanorcare could you share a way to reproduce the issue? Like country and number. And the workaround code if you don’t mind. I’ll test soon.

@YewoMhango
Copy link

@YewoMhango @suman-eleanorcare could you share a way to reproduce the issue? Like country and number. And the workaround code if you don’t mind. I’ll test soon.

An example of a country code which reproduced the issue was selecting the UK. But I don't have access to my computer currently, so I can't send the workaround code immediately

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment