PhoneField

Phone field
for design systems

Compose country picker + number input with Base UI. Parsing and validation stay aligned with libphonenumber via a minimal primitive API.

1import { PhoneField } from "phonefield";
2
3export function App() {
4 return (
5 <PhoneField.Root>
6 <PhoneField.Country />
7 <PhoneField.Input />
8 </PhoneField.Root>
9 );
10}
Live

Playground

Input states are exposed via data-valid and data-invalid.

Number format lens

Real-time output from PhoneFieldUtils.parse and PhoneFieldUtils.isValid.

National

-

International

-

E.164

-

RFC3966 URI

-

Meta

- | +-

Validity

false (value) | false (raw)

Documentation

Installation, usage patterns, and API reference. Everything you need to integrate PhoneField into your design system.

Getting started

Installation

Install with your preferred manager. Copy the command with one click.

pnpm add phonefield

Quick Start

Minimal setup. Root can run uncontrolled by default and gives a normalized PhoneField.Value output.

1import { PhoneField } from "phonefield";
2
3export function SignupPhone() {
4 return (
5 <PhoneField.Root defaultCountry="US" lang="en">
6 <PhoneField.Country />
7 <PhoneField.Input />
8 </PhoneField.Root>
9 );
10}

Controlled Mode

Use when your form or global state owns the value and you need full control over updates.

1import * as React from "react";
2import { PhoneField } from "phonefield";
3
4export function CheckoutPhone() {
5 const [phone, setPhone] = React.useState<PhoneField.Value>({
6 countryIso2: "US",
7 countryDialCode: "1",
8 nationalNumber: "",
9 e164: null,
10 isValid: false,
11 });
12
13 return (
14 <PhoneField.Root value={phone} onValueChange={setPhone}>
15 <PhoneField.Country />
16 <PhoneField.Input />
17 </PhoneField.Root>
18 );
19}

Styling country select

PhoneField.Country has no styles by default. So you can style it as you want following your design system rules.

1// This classNames preset is based entirely on Base UI's example:
2// https://base-ui.com/react/components/combobox#input-inside-popup
3// You can style the Combobox however you want.
4<PhoneField.Country
5 classNames={{
6 // Trigger button that opens the country Combobox.
7 trigger:
8 "inline-flex h-10 min-w-[7.5rem] cursor-default select-none items-center justify-between gap-2 whitespace-nowrap rounded-xl border border-gray-200 bg-white pr-2.5 pl-3 text-base text-gray-900 transition-colors hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 data-[popup-open]:bg-gray-100",
9 // Trigger icon.
10 icon: "flex text-gray-600",
11 // Positioning layer for z-index and popup alignment.
12 positioner: "z-50",
13 // Popup panel with dimensions and enter/exit transitions.
14 popup:
15 "origin-[var(--transform-origin)] flex max-w-[var(--available-width)] max-h-[24rem] flex-col overflow-hidden rounded-lg bg-[canvas] text-gray-900 shadow-lg shadow-gray-200 outline-1 outline-gray-200 transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0 dark:shadow-none dark:-outline-offset-1 dark:outline-gray-300",
16 // Wrapper around the search input.
17 searchInputContainer: "shrink-0 p-2",
18 // Search input inside the popup.
19 searchInput:
20 "h-10 w-full font-normal rounded-md border border-gray-200 px-3 text-base text-gray-900 focus:outline focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800",
21 // Empty state when no country matches.
22 empty: "p-4 text-[0.925rem] leading-4 text-gray-600 empty:m-0 empty:p-0",
23 // Scrollable list of countries.
24 list: "min-h-0 flex-1 overflow-y-auto scroll-py-2 py-2 overscroll-contain empty:p-0",
25 // Country row and highlighted state.
26 item:
27 "grid min-w-[max(16rem,var(--anchor-width))] cursor-default grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-lg py-2.5 pr-4 pl-4 text-base leading-5 outline-none select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-gray-50 data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-2 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:rounded-lg data-[highlighted]:before:bg-gray-900",
28 }}
29 ...
30/>

Country Subset

Limit the available countries from Root using ISO codes.

1<PhoneField.Root countries={["US", "CA", "MX"]}>
2 <PhoneField.Country />
3 <PhoneField.Input />
4</PhoneField.Root>

Internationalization

Localize country names/sorting with lang

1<PhoneField.Root lang="es-AR" defaultCountry="AR">
2 <PhoneField.Country
3 inputPlaceholder="Buscar país"
4 noResultsText="No se encontraron países"
5 />
6 <PhoneField.Input />
7</PhoneField.Root>

Forms & submission

Use uncontrolled for simple forms. Switch to controlled when external state needs to orchestrate validation, steps, or async flows.

Uncontrolled + FormData (Client / Server)

Set Root name to serialize the full PhoneField.Value as JSON in a hidden input. Read it from FormData on client or server.

1import { PhoneField } from "phonefield";
2import { PhoneFieldUtils } from "phonefield/utils";
3
4// Uncontrolled is the default: omit `value` and `onValueChange`.
5<form
6 onSubmit={(event) => {
7 event.preventDefault();
8 const formData = new FormData(event.currentTarget);
9 const phone = PhoneFieldUtils.fromFormData(formData, "phone");
10 // phone -> PhoneField.Value | null
11 }}
12>
13 <PhoneField.Root name="phone" defaultCountry="US">
14 <PhoneField.Country />
15 <PhoneField.Input />
16 </PhoneField.Root>
17</form>
18
19// Server side:
20const formData = await request.formData();
21const phone = PhoneFieldUtils.fromFormData(formData, "phone");

Validity States

Pair PhoneField with Base UI Field to show invalid states and clear error messages using data-valid and data-invalid attributes.

1import { Field } from "@base-ui/react/field";
2
3const hasNumber = value.nationalNumber.trim().length > 0;
4const showError = hasNumber && !value.isValid;
5
6<Field.Root invalid={showError} className="space-y-2">
7 <Field.Label>Phone</Field.Label>
8
9 <PhoneField.Root value={value} onValueChange={setValue}>
10 <PhoneField.Country />
11 <PhoneField.Input className="data-invalid:border-red-500 data-valid:border-emerald-500" />
12 </PhoneField.Root>
13
14 <Field.Error match={showError}>Invalid phone number</Field.Error>
15</Field.Root>

API

PhoneField.Root props

Root state and serialization API. Also accepts every div prop except defaultValue.

PropTypeDefaultDescription
valuePhoneField.Value-Controlled value for the full phone object.
defaultValuePhoneField.Value-Initial value for uncontrolled usage.
onValueChange(value: PhoneField.Value) => void-Callback fired when country or number changes.
defaultCountryPhoneField.CountryCodeValue"US" if available, otherwise first availableInitial country when no value is provided.
countriesreadonly PhoneField.CountryCodeValue[]all supportedRestricts the country list to a subset.
langPhoneField.Lang"en"Locale used for country labels and sorting.
namestring-Serializes the value as JSON in a hidden input for FormData.
formatOnTypebooleantrueFormats as you type based on selected country.
...divPropsReact.ComponentPropsWithoutRef<"div">-Includes children, className, events, and ARIA attributes.

PhoneField.Country props

Country picker copy, rendering, and popup customization props.

PropTypeDefaultDescription
placeholderReact.ReactNode"Select country"Placeholder shown in the trigger when no country is selected.
noResultsTextReact.ReactNode"No countries found"Message displayed when search has no matches.
inputPlaceholderstring"Search country"Placeholder for the popup search input.
iconReact.ReactNodeChevronUpDownReplaces the default trigger icon.
classNamesPhoneField.CountryClassNames-Part-level classes: trigger, icon, popup, positioner, searchInput, searchInputContainer, list, item, empty.
positioningPhoneField.CountryPositioningside: "bottom", align: "start", sideOffset: 4Popup placement and collision behavior options.
renderCountryItem(country: PhoneField.Country) => React.ReactNode-Custom renderer for each dropdown country row.
renderCountryValue(country: PhoneField.Country) => React.ReactNode-Custom renderer for the selected country in the trigger.

PhoneField.Input props

Input forwards the full BaseInput.Props surface from @base-ui/react/input.

PropTypeDefaultDescription
typeReact.HTMLInputTypeAttribute"text"Input type passed to Base UI Input.
onValueChangeBaseInput.Props["onValueChange"]-Called with the updated national number.
classNameBaseInput.Props["className"]-Styles the underlying input element.
...baseInputPropsBaseInput.Props-Includes standard input props like name, id, placeholder, disabled, required, events, and ARIA attributes.

Reference

Formatting + Utils

Validate and format on frontend or backend. parse() returns libphonenumber's PhoneNumber (formatNational, formatInternational, getURI). isValid and parse accept Value or E.164 string; optional { defaultCountry } for national-number strings.

1import { PhoneFieldUtils } from "phonefield/utils";
2
3// parse() returns libphonenumber PhoneNumber: formatNational(), formatInternational(), getURI()
4const parsed = PhoneFieldUtils.parse(value);
5
6const output = {
7 isValid: PhoneFieldUtils.isValid(value),
8 e164: value.e164,
9 national: parsed?.formatNational(),
10 international: parsed?.formatInternational(),
11};
12
13// Frontend or backend:
14PhoneFieldUtils.isValid("+14155552671");
15PhoneFieldUtils.fromFormData(formData, "phone");
16PhoneFieldUtils.getCountries("es-AR"); // Map<iso2, country> (locale for names)
17PhoneFieldUtils.countries; // default map (en)

PhoneFieldUtils API

Reference for phonefield/utils. Same helpers on client and server.

Method / propertyDescription
parse(value, options?)Parse Value or E.164 → libphonenumber PhoneNumber (formatNational, formatInternational, getURI). options.defaultCountry for national numbers.
isValid(value, options?)Validate Value or raw string. Same options as parse.
fromFormData(formData, name)Read serialized PhoneField.Value from FormData → Value | null.
getCountries(locale?)Map ISO2 → country (name, dialCode, flag). Locale for display names.
countriesDefault countries map (en). Getter, no locale argument.