Composable React primitive

A phone field that fits your design system.

Accessible Base UI primitives, canonical libphonenumber output, and complete styling control—without inheriting somebody else's UI.

Composable

3 primitives

Canonical

E.164 output

Localized

Global metadata

app.tsx
1import { PhoneField } from "phonefield";23export function App() {4  return (5    <PhoneField.Root>6      <PhoneField.Country />7      <PhoneField.Input aria-label="Phone number" />8    </PhoneField.Root>9  );10}
Interactive playground

See every state before you ship it.

Enter a number or load a sample. The field and its canonical output update together.

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

Load a valid sample

Canonical output

Live values from parse.

Incomplete
National
-
International
-
E.164
-
RFC3966 URI
-
Meta
- | +-
Validity
false

Reference

Documentation

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

Getting started

Install the package, compose the primitives, and tailor them to your product.

  1. 01

    Install

    Add the package and peer dependencies.

  2. 02

    Compose

    Render Root, Country, and Input.

  3. 03

    Integrate

    Read Value or submit through FormData.

Installation

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

pnpm add phonefield

Supported versions: @base-ui/react >=1.6 <2, react >=19 <20, react-dom >=19 <20, and Node.js 22 or newer.

Quick start

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

TSX · 10 lines
1import { PhoneField } from "phonefield";23export function SignupPhone() {4	return (5		<PhoneField.Root defaultCountry="US" lang="en">6			<PhoneField.Country />7			<PhoneField.Input aria-label="Phone number" />8		</PhoneField.Root>9	);10}

Controlled mode

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

TSX · 16 lines
1import { PhoneField } from "phonefield";2import * as React from "react";34export function CheckoutPhone() {5	const [phone, setPhone] = React.useState<PhoneField.InputValue>({6		countryIso2: "US",7		nationalNumber: "",8	});910	return (11		<PhoneField.Root value={phone} onValueChange={setPhone}>12			<PhoneField.Country />13			<PhoneField.Input aria-label="Phone number" />14		</PhoneField.Root>15	);16}
Keep the root controlled or uncontrolled for its complete lifetime. defaultValue and defaultCountry are initial values and do not reset the field after mount. Controlled inputs may provide only PhoneField.InputValue; derived fields are rebuilt and onValueChange always emits the complete PhoneField.Value.
Undo and redo work with formatted values. While the phone input has focus, use Cmd/Ctrl+Z to undo and Cmd/Ctrl+Shift+Z or Ctrl+Y to redo. PhoneField restores the number, selected country, and selection in native-style transactions: a typing run undoes as one step, while undoing consecutive deletions restores and selects the removed digits. Paste, cut, drop, and country changes are independent steps. The last 100 transactions are retained; replacing a controlled value starts a new history.
Paste an international number beginning with + and PhoneField selects its detected country when available, then keeps only the nationally formatted number in the input. If the detected country is excluded by countries, the original text is preserved and emitted as invalid instead of being reinterpreted. The + prefix is accepted from paste but blocked during direct typing; a preserved prefix can still be removed with Backspace. A valid number becomes invalid when its detected country differs from the country selected by the user, including countries that share a calling code.

Country subset

Limit the available countries from Root using ISO codes.

TSX · 10 lines
1import { PhoneField } from "phonefield";23export function NorthAmericaPhone() {4	return (5		<PhoneField.Root countries={["US", "CA", "MX"]}>6			<PhoneField.Country />7			<PhoneField.Input aria-label="Phone number" />8		</PhoneField.Root>9	);10}

Internationalization

Localize country names and sorting with the lang prop.

TSX · 13 lines
1import { PhoneField } from "phonefield";23export function ArgentinaPhone() {4	return (5		<PhoneField.Root lang="es-AR" defaultCountry="AR">6			<PhoneField.Country7				inputPlaceholder="Buscar país"8				noResultsText="No se encontraron países"9			/>10			<PhoneField.Input aria-label="Número de teléfono" />11		</PhoneField.Root>12	);13}
SSR-safe country defaults. Resolve the account or tenant country on the server and pass it through defaultCountry. Do not replace it after mount from a browser-only locale lookup: defaults are intentionally read once, and replacing them can overwrite a user's selection.

Styling

Use the typed class preset for Tailwind and per-instance styles. Use stable data-slot selectors when your design system is CSS-first.

Recommended

Using Tailwind or class utilities?

Let Root own the shared border and focus ring. Use cn for the input and one hoisted CountryClassNames preset for the trigger and popup.

Alternative

Using CSS or CSS Modules?

Target the stable data-slot anatomy. Popup selectors must be global because Base UI renders the popup in a portal.

Present Root as one visual field, but keep Country and Input as separate accessible controls. Named Tailwind group variants are ideal for local state, such as rotating the trigger icon. The popup still needs CountryClassNames because it is rendered in a portal and is not a Root descendant.

Production preset

A reusable wrapper with one border and focus ring around two accessible controls. Root owns field-level state, while CountryClassNames styles the trigger and every portaled popup part.

TSX · 121 lines
1import { ChevronDownIcon } from "lucide-react";2import { PhoneField } from "phonefield";3import { inputClassName } from "@/components/ui/input";4import {5	InputGroup,6	InputGroupAddon,7	InputGroupInput,8} from "@/components/ui/input-group";9import { cn } from "@/lib/utils";1011const defaultCountryClassNames = {12	trigger:13		"group/phone-country-trigger flex h-full w-fit shrink-0 cursor-default items-center gap-2 border-input border-r bg-transparent px-3 text-left text-sm outline-none select-none transition-colors duration-150 hover:bg-accent/50 focus-visible:bg-accent/50 data-popup-open:bg-accent/50",14	icon: "shrink-0 text-muted-foreground transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-data-popup-open/phone-country-trigger:rotate-180 motion-reduce:transition-none",15	positioner: "isolate z-50",16	popup:17		"max-h-(--available-height) w-72 max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-2xl ring-1 ring-foreground/5 transition-[transform,opacity] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] data-ending-style:[transform:scale(0.97)] data-ending-style:opacity-0 data-starting-style:[transform:scale(0.97)] data-starting-style:opacity-0 motion-reduce:transform-none motion-reduce:transition-none",18	searchInputContainer: "p-1",19	searchInput: inputClassName,20	list: "max-h-72 scroll-py-1 overflow-y-auto overscroll-contain p-1",21	item: "flex cursor-default items-center rounded-lg px-3 py-2 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-selected:bg-accent data-selected:text-accent-foreground",22	empty: "p-6 text-center text-sm text-muted-foreground",23} satisfies PhoneField.CountryClassNames;2425const defaultCountryIcon = <ChevronDownIcon aria-hidden className="size-4" />;2627function renderCountryItem(country: PhoneField.Country) {28	return (29		<span className="flex min-w-0 flex-1 items-center gap-2.5">30			<span aria-hidden className="shrink-0">31				{country.flag}32			</span>33			<span className="min-w-0 flex-1 truncate">{country.name}</span>34			<span className="shrink-0 text-muted-foreground">{country.dialCode}</span>35		</span>36	);37}3839function renderCountryValue(country: PhoneField.Country) {40	return (41		<span className="flex min-w-0 items-center gap-2">42			<span aria-hidden>{country.flag}</span>43			<span>{country.dialCode}</span>44			<span className="sr-only">{country.name}</span>45		</span>46	);47}4849type ProductionPhoneFieldProps = Omit<PhoneField.RootProps, "children"> & {50	countryClassNames?: PhoneField.CountryClassNames;51	countryProps?: Omit<PhoneField.CountryProps, "classNames">;52	inputClassName?: string;53	inputProps?: Omit<PhoneField.InputProps, "className">;54};5556export function ProductionPhoneField({57	className,58	countryClassNames = defaultCountryClassNames,59	countryProps,60	defaultCountry = "US",61	inputClassName: inputClassNameOverride,62	inputProps,63	...props64}: ProductionPhoneFieldProps) {65	const {66		icon = defaultCountryIcon,67		renderCountryItem: countryItemRenderer = renderCountryItem,68		renderCountryValue: countryValueRenderer = renderCountryValue,69		slotProps,70		...resolvedCountryProps71	} = countryProps ?? {};72	const { trigger: countryTriggerProps, ...resolvedCountrySlotProps } =73		slotProps ?? {};74	const {75		"aria-label": inputAriaLabel = "Phone number",76		...resolvedInputProps77	} = inputProps ?? {};7879	return (80		<PhoneField.Root81			{...props}82			className={cn("w-full", className)}83			defaultCountry={defaultCountry}84		>85			<InputGroup86				className={cn(87					"h-10 overflow-hidden bg-background shadow-sm transition-[border-color,box-shadow] duration-150",88					"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/50",89					"has-data-popup-open:border-ring has-data-popup-open:ring-2 has-data-popup-open:ring-ring/50",90					"has-aria-invalid:border-destructive has-aria-invalid:ring-2 has-aria-invalid:ring-destructive/20",91				)}92			>93				<PhoneField.Input94					render={<InputGroupInput />}95					{...resolvedInputProps}96					aria-label={inputAriaLabel}97					className={cn("h-full px-3", inputClassNameOverride)}98				/>99				<InputGroupAddon100					align="inline-start"101					className="h-full cursor-default p-0"102				>103					<PhoneField.Country104						{...resolvedCountryProps}105						classNames={countryClassNames}106						icon={icon}107						renderCountryItem={countryItemRenderer}108						renderCountryValue={countryValueRenderer}109						slotProps={{110							...resolvedCountrySlotProps,111							trigger: {112								"aria-label": "Country",113								...countryTriggerProps,114							},115						}}116					/>117				</InputGroupAddon>118			</InputGroup>119		</PhoneField.Root>120	);121}

CSS data slots

The same grouped presentation works without Tailwind. Root owns the shell; stable data slots target the inline controls and portaled popup. classNames remains available for per-instance classes.

CSS · 61 lines
1/* data-slot selectors are global because the popup is rendered in a portal. */2[data-slot="phone-field"] {3	display: flex;4	height: 2.5rem;5	min-width: 0;6	border: 1px solid #e2e8f0;7	border-radius: 0.5rem;8	background: white;9	overflow: hidden;10	transition:11		border-color 150ms,12		box-shadow 150ms;13}1415[data-slot="phone-field"]:focus-within,16[data-slot="phone-field"]:has([data-popup-open]) {17	border-color: #0ea5e9;18	box-shadow: 0 0 0 3px rgb(14 165 233 / 0.15);19}2021[data-slot="phone-field"]:has([aria-invalid="true"]),22[data-slot="phone-field"]:has([data-invalid]) {23	border-color: #ef4444;24	box-shadow: 0 0 0 3px rgb(239 68 68 / 0.1);25}2627[data-slot="phone-field-country-trigger"] {28	display: flex;29	height: 100%;30	align-items: center;31	gap: 0.5rem;32	border: 0;33	border-right: 1px solid #e2e8f0;34	background: transparent;35	outline: none;36	padding-inline: 0.75rem;37}3839[data-slot="phone-field-input"] {40	width: auto;41	height: 100%;42	min-width: 0;43	flex: 1;44	border: 0;45	background: transparent;46	outline: none;47	padding-inline: 0.75rem;48}4950[data-slot="phone-field-country-popup"] {51	width: 18rem;52	transform-origin: var(--transform-origin);53	overflow: hidden;54	border-radius: 0.75rem;55	background: white;56	box-shadow: 0 20px 40px rgb(15 23 42 / 0.14);57}5859[data-slot="phone-field-country-item"][data-highlighted] {60	background: #f1f5f9;61}

Stable anatomy

Base UI state attributes compose with these slots. For example, style an open trigger with [data-popup-open] and a focused country with [data-highlighted].

phone-field
Root layout
phone-field-hidden-input
FormData payload
phone-field-input
Telephone input
phone-field-country-trigger
Country trigger
phone-field-country-icon
Trigger icon
phone-field-country-positioner
Popup geometry wrapper
phone-field-country-popup
Portaled popup
phone-field-country-search-container
Search layout
phone-field-country-search-input
Country search
phone-field-country-list
Scrollable list
phone-field-country-item
Country option
phone-field-country-empty
Empty state

Forms & submission

Choose the state model that matches your form and rebuild trusted values at the boundary.

Use uncontrolled for simple forms. Switch to controlled when external state needs to orchestrate validation, steps, or async flows.
Validate again on the server. Treat submitted country and national-number fields as untrusted. Use fromFormData at the server boundary to rebuild E.164 and validity instead of accepting derived client values.

Uncontrolled + FormData (Client / Server)

Set Root name to submit only countryIso2 and nationalNumber. fromFormData validates those untrusted source fields and rebuilds the derived value on client or server.

TSX · 25 lines
1import { PhoneField } from "phonefield";2import { fromFormData } from "phonefield/utils";34export function ContactForm() {5	return (6		<form7			onSubmit={(event) => {8				event.preventDefault();9				const phone = fromFormData(new FormData(event.currentTarget), "phone");10				console.log(phone);11			}}12		>13			<PhoneField.Root name="phone" defaultCountry="US">14				<PhoneField.Country />15				<PhoneField.Input aria-label="Phone number" />16			</PhoneField.Root>17			<button type="submit">Continue</button>18		</form>19	);20}2122// The same helper is server-compatible.23export function parseSubmittedPhone(formData: FormData) {24	return fromFormData(formData, "phone");25}

Validity states

Give the phone input a native label, name the country trigger independently, and expose invalid state with aria-invalid. Do not wrap both controls in one Base UI Field: Field represents a single form control.

TSX · 44 lines
1import { PhoneField } from "phonefield";2import * as React from "react";34export function ValidatedPhone() {5	const phoneInputId = React.useId();6	const phoneErrorId = `${phoneInputId}-error`;7	const [value, setValue] = React.useState<PhoneField.InputValue>({8		countryIso2: "US",9		nationalNumber: "",10	});11	const [isValid, setIsValid] = React.useState(true);1213	return (14		<div className="space-y-2">15			<label htmlFor={phoneInputId}>Phone</label>16			<PhoneField.Root17				value={value}18				onValueChange={(nextValue) => {19					setValue(nextValue);20					setIsValid(!nextValue.nationalNumber || nextValue.isValid);21				}}22			>23				<PhoneField.Country24					slotProps={{25						trigger: {26							"aria-label": "Country",27						},28					}}29				/>30				<PhoneField.Input31					aria-describedby={!isValid ? phoneErrorId : undefined}32					aria-invalid={!isValid || undefined}33					id={phoneInputId}34					className="aria-invalid:border-red-500"35				/>36			</PhoneField.Root>37			{!isValid ? (38				<p id={phoneErrorId} role="alert">39					Invalid phone number40				</p>41			) : null}42		</div>43	);44}

Component API

Props for the root state container, country picker, and phone input.

PhoneField.Root props

State, country scope, and form serialization. Root is the only owner of domain value changes and submission name.

Prop / methodTypeDefaultDescription
valuePhoneField.InputValue | PhoneField.Value-Controlled source fields. Derived fields are rebuilt.
defaultValuePhoneField.InputValue | PhoneField.Value-Initial uncontrolled source fields; later changes are ignored.
onValueChange(value: PhoneField.Value) => void-The only domain callback; fires when country or number changes.
defaultCountryPhoneField.CountryCode"US" or first availableInitial country when no value is provided.
countriesreadonly PhoneField.CountryCode[]allRestricts the country list and international-paste auto-selection to a subset.
langPhoneField.Lang"en"Locale used for country labels and sorting.
namestring-Serializes countryIso2 and nationalNumber into one FormData entry.
formatOnTypebooleantrueFormats as the user types for the selected country.
...divPropsReact.ComponentPropsWithoutRef<"div">-Children, className, events, ref, and ARIA attributes.

PhoneField.Country props

Copy, content, styling, positioning, and advanced behavioral customization for the country picker.

Prop / methodTypeDefaultDescription
placeholderReact.ReactNode"Select country"Trigger placeholder when no country is selected.
noResultsTextReact.ReactNode"No countries found"Message displayed when search has no matches.
inputPlaceholderstring"Search country"Placeholder and fallback accessible name for search.
iconReact.ReactNodeChevronUpDownReplaces the trigger icon.
classNamesPhoneField.CountryClassNames-Recommended for Tailwind and per-instance classes. Use data-slot for global CSS.
positioningPhoneField.CountryPositioningbottom / start / 4pxThe only popup geometry and collision seam.
renderCountryItem(country) => React.ReactNode-Custom country row content.
renderCountryValue(country) => React.ReactNode-Custom selected-country content.
slotPropsPhoneField.CountrySlotProps-Advanced behavioral and ARIA props. Styling and positioning are intentionally omitted.

PhoneField.Input props

Native input behavior and styling. Value changes and form serialization remain owned by Root.

Prop / methodTypeDefaultDescription
typeReact.HTMLInputTypeAttribute"tel"Telephone-friendly input type; consumers may override it.
inputModeReact.HTMLAttributes<HTMLInputElement>["inputMode"]"tel"Requests a telephone-friendly virtual keyboard.
autoCompletestring"tel-national"Uses national-number autocomplete semantics.
classNameBaseInput.Props["className"]-Styles the underlying input.
...inputPropsBaseInput.Props-Native events and ARIA props, excluding value, defaultValue, name, and onValueChange.

Utilities

Parse, validate, format, and serialize the same way on the client and server.

Formatting and utilities

Validate and format on frontend or backend. parse() returns libphonenumber's PhoneNumber. String parsing is strict by default; pass { defaultCountry } for national numbers or opt into { extract: true } for arbitrary text.

TSX · 26 lines
1import type { PhoneField } from "phonefield";2import {3	buildValue,4	countries,5	fromFormData,6	getCountries,7	isValid,8	parse,9	toFormValue,10} from "phonefield/utils";1112export function inspectPhone(value: PhoneField.Value, formData: FormData) {13	const parsed = parse(value);14	const extracted = parse("Call +1 415 555 2671", { extract: true });15	const unitedStates = getCountries("en").get("US");1617	return {18		built: unitedStates ? buildValue(unitedStates, "4155552671", true) : null,19		defaultCountry: countries.get("US"),20		extracted,21		fromForm: fromFormData(formData, "phone"),22		isValid: isValid(value),23		parsed,24		serialized: toFormValue(value),25	};26}

Named utilities

Server-compatible named exports from phonefield/utils. Import the helpers you use or import * as PhoneFieldUtils.

Prop / methodTypeDefaultDescription
parse(value, options?)(string | Value, ParseOptions?) => PhoneNumber | undefined-Parse a strict string or Value into libphonenumber PhoneNumber.
isValid(value, options?)(string | Value, ParseOptions?) => boolean-Validate a strict string or require a Value to match its selected country.
buildValue(country, number, format)(Country, string, boolean) => Value-Build a canonical Value from country metadata and a national number.
fromFormData(formData, name)(FormData, string) => Value | null-Validate submitted source fields and rebuild a canonical Value.
toFormValue(value)(Value) => FormValue-Return countryIso2 and nationalNumber for serialization.
getCountries(locale?)(Lang?) => CountryMap-Return localized, runtime-immutable country metadata.
countriesPhoneField.CountryMapEnglish mapDefault country metadata without locale lookup.

Migrate from 0.x to v1

The value model and submitted payload remain compatible. The migration removes overlapping entry points and gives each concern one owner.

This is a mechanical migration

Upgrade the package, apply the five replacements below, then run your typecheck. No phone data migration is required.

pnpm add phonefield@^1

Import utilities directly

The dedicated PhoneFieldUtils facade is gone. Named exports tree-shake naturally; namespace imports remain available when preferred.

0.x · replace
1import { PhoneFieldUtils } from "phonefield/utils";23const parsed = PhoneFieldUtils.parse(value);
v1 · use
1import { parse } from "phonefield/utils";23const parsed = parse(value);

Listen for the complete value at Root

Root is the single authority for phone state. Input still accepts native events when low-level DOM access is necessary.

0.x · replace
1<PhoneField.Root>2  <PhoneField.Input3    onValueChange={handleNationalNumber}4  />5</PhoneField.Root>
v1 · use
1<PhoneField.Root onValueChange={handlePhoneChange}>2  <PhoneField.Input3    onChange={handleNativeInputEvent}4  />5</PhoneField.Root>

Put the submission name on Root

Root serializes one minimal, structured FormData value. Rebuild and validate its derived fields with fromFormData at the boundary.

0.x · replace
1<PhoneField.Root>2  <PhoneField.Input name="phone" />3</PhoneField.Root>
v1 · use
1<PhoneField.Root name="phone">2  <PhoneField.Input />3</PhoneField.Root>45const phone = fromFormData(formData, "phone");

Separate styling, positioning, and behavior

Each concern now has one clear seam: classNames for appearance, positioning for geometry, and slotProps for behavior or ARIA.

0.x · replace
1<PhoneField.Country2  slotProps={{3    trigger: { className: "trigger" },4    positioner: { side: "top" },5  }}6/>
v1 · use
1<PhoneField.Country2  classNames={{ trigger: "trigger" }}3  positioning={{ side: "top" }}4  slotProps={{5    trigger: { "aria-label": "Country" },6  }}7/>

Use the PhoneField type namespace

Component and domain types now live under the same discoverable namespace as the runtime primitives.

0.x · replace
1import type { PhoneFieldValue } from "phonefield";23let phone: PhoneFieldValue;
v1 · use
1import type { PhoneField } from "phonefield";23let phone: PhoneField.Value;

Migration checklist

  • No PhoneFieldUtils facade imports remain
  • onValueChange and name live on Root
  • Country classes use classNames
  • Public types use PhoneField.*
  • React, Base UI, and Node match supported versions
  • Typecheck and form submission tests pass