v2.0Composable parts, field state, and a new hook

The phone input your design system deserves.

Accessible Base UI primitives with canonical libphonenumber output. Works with zero config, composes down to every part, and styles with plain className.

$ pnpm add phonefield @base-ui/react
Formats as you type
  • data-country="US"
  • data-filled
  • data-valid
  • data-invalid
  • data-focused
  • data-touched
  • data-dirty
onValueChange(value)Incomplete
{}

What it handles

The details you'd rather not build twice.

Every card below is the real component. Poke at it.

Field state, for free

Root mirrors its state as data attributes, so validation styling is one class away: data-invalid:border-destructive. Type an incomplete number, then tab away. The error message is <PhoneField.Error />.

  • data-country="US"
  • data-filled
  • data-valid
  • data-invalid
  • data-focused
  • data-touched
  • data-dirty

Paste anything

International numbers pick their country and collapse to national format. Click a clipboard chip.

Never too long

Digits past the longest possible number for the country are rejected as you type or paste.

Speaks every language

lang localizes names, sorting, and search through Intl. No translation files.

Alemania · Japón · Estados Unidos · España

Undo that

Native-feeling history over formatted values: ⌘Z undoes a typing run, a paste, or a country change as one step.

Bring your own picker

usePhoneField() gives custom parts the value, state, and setters, so any picker can drive the field. Here: shadcn's Select in place of the built-in combobox.

import { usePhoneField } from "phonefield";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

function CountrySelect() {
  const { country, countries, setCountry } = usePhoneField();
  return (
    <Select value={country.iso2} onValueChange={setCountry}>
      <SelectTrigger><SelectValue /></SelectTrigger>
      <SelectContent>
        {countries.map((c) => (
          <SelectItem key={c.iso2} value={c.iso2}>{c.flag} {c.name}</SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

Composition

From zero config to entirely yours.

Every part is a Base UI part: it takes className, render, and its state as data attributes. One API, any design.

Group is the shell: data-focused and data-invalid drive the ring, while Label and Error are wired for you. Copy it into your components folder.

phone-input.tsx
1import { CheckIcon, ChevronDownIcon } from "lucide-react";2import { PhoneField } from "phonefield";3import type * as React from "react";4import { cn } from "@/lib/utils";56type PhoneInputProps = Omit<PhoneField.RootProps, "className"> & {7	className?: string;8	label?: React.ReactNode;9	description?: React.ReactNode;10	placeholder?: PhoneField.InputProps["placeholder"];11	inputProps?: Omit<PhoneField.InputProps, "placeholder" | "className">;12};1314export function PhoneInput({15	className,16	label,17	description,18	placeholder,19	inputProps,20	children,21	...props22}: PhoneInputProps) {23	return (24		<PhoneField.Root25			{...props}26			className={cn("grid w-full gap-1.5", className)}27		>28			{label ? (29				<PhoneField.Label className="text-sm font-medium text-foreground">30					{label}31				</PhoneField.Label>32			) : null}33			<PhoneField.Group34				className={cn(35					"flex h-10 w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-background shadow-xs transition-[border-color,box-shadow] duration-150",36					"data-focused:border-ring data-focused:ring-3 data-focused:ring-ring/40",37					"data-invalid:border-destructive data-invalid:ring-destructive/20",38					"data-disabled:cursor-not-allowed data-disabled:opacity-50",39				)}40			>41				<PhoneField.Country>42					<PhoneField.CountryTrigger className="group/trigger flex shrink-0 cursor-default items-center gap-1.5 border-input border-r px-3 text-sm outline-none select-none transition-colors duration-150 hover:bg-accent/60 focus-visible:bg-accent data-popup-open:bg-accent">43						<PhoneField.CountryValue>44							{(country) => (45								<>46									<span aria-hidden className="text-base leading-none">47										{country.flag}48									</span>49									<span className="tabular-nums text-muted-foreground">50										{country.dialCode}51									</span>52								</>53							)}54						</PhoneField.CountryValue>55						<PhoneField.CountryIcon className="text-muted-foreground transition-transform duration-150 ease-out group-data-popup-open/trigger:rotate-180 motion-reduce:transition-none">56							<ChevronDownIcon className="size-3.5" />57						</PhoneField.CountryIcon>58					</PhoneField.CountryTrigger>59					<PhoneField.CountryPortal>60						<PhoneField.CountryPositioner61							className="isolate z-50"62							sideOffset={6}63						>64							<PhoneField.CountryPopup className="flex max-h-[min(22rem,var(--available-height))] w-72 max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-xl ring-1 ring-foreground/10 transition-[transform,opacity] duration-150 ease-out 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:transition-none">65								<div className="border-b p-1.5">66									<PhoneField.CountrySearch className="h-8 w-full rounded-md bg-muted/60 px-2.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/50" />67								</div>68								<PhoneField.CountryEmpty className="px-3 py-6 text-center text-sm text-muted-foreground empty:hidden" />69								<PhoneField.CountryList className="min-h-0 flex-1 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0 [&_[data-preferred]]:mb-1 [&_[data-preferred]]:border-b [&_[data-preferred]]:pb-1">70									{(country) => (71										<PhoneField.CountryItem72											country={country}73											className="flex cursor-default items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"74										>75											<span aria-hidden className="text-base leading-none">76												{country.flag}77											</span>78											<span className="min-w-0 flex-1 truncate">79												{country.name}80											</span>81											<span className="tabular-nums text-muted-foreground">82												{country.dialCode}83											</span>84											<PhoneField.CountryItemIndicator className="text-foreground">85												<CheckIcon className="size-3.5" />86											</PhoneField.CountryItemIndicator>87										</PhoneField.CountryItem>88									)}89								</PhoneField.CountryList>90							</PhoneField.CountryPopup>91						</PhoneField.CountryPositioner>92					</PhoneField.CountryPortal>93				</PhoneField.Country>94				<PhoneField.Input95					aria-label={label ? undefined : "Phone number"}96					{...inputProps}97					placeholder={placeholder}98					className="h-full min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed md:text-sm"99				/>100			</PhoneField.Group>101			{description ? (102				<PhoneField.Description className="text-xs text-muted-foreground">103					{description}104				</PhoneField.Description>105			) : null}106			<PhoneField.Error className="text-xs text-destructive" />107			{children}108		</PhoneField.Root>109	);110}
Interactive playground

Flip a prop, see the field, copy the code.

Every option maps to a Root prop. The output updates from onValueChange and usePhoneField.

Tab away from an incomplete number to see data-invalid.

Root props
Incomplete

Root state

  • data-country="US"
  • data-filled
  • data-valid
  • data-invalid
  • data-focused
  • data-touched
  • data-dirty

Formats

National
-
International
-
E.164
-
RFC 3966
-
Last reason
-

Reference

Documentation

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

Getting started

Install the package, render the three primitives, and compose more only where you need it.

  1. 01

    Install

    Add the package and Base UI.

  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

Peer dependencies: @base-ui/react >=1.9 <2, react >=19 <20, and react-dom >=19 <20. Building the package requires Node.js 22.18 or newer.

Quick start

Zero config. Country renders a complete, accessible picker with search; Input formats as you type. Root runs uncontrolled and emits a normalized PhoneField.Value.

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

Labels & errors

Root is a plain wrapper. Label targets the input, Group is the visual shell for Country and Input, and Description and Error are linked with aria-describedby. Error renders only while the field is invalid.

TSX · 15 lines
1import { PhoneField } from "phonefield";23export function AccountPhone() {4	return (5		<PhoneField.Root name="phone" required>6			<PhoneField.Label>Phone</PhoneField.Label>7			<PhoneField.Group>8				<PhoneField.Country />9				<PhoneField.Input />10			</PhoneField.Group>11			<PhoneField.Description>Used for sign-in codes.</PhoneField.Description>12			<PhoneField.Error />13		</PhoneField.Root>14	);15}

Controlled mode

Use when your form or global state owns the value. The second onValueChange argument says why the value changed.

TSX · 22 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.Root12			value={phone}13			onValueChange={(next, { reason }) => {14				setPhone(next);15				if (reason === "paste") console.log("Pasted", next.e164);16			}}17		>18			<PhoneField.Country />19			<PhoneField.Input aria-label="Phone number" />20		</PhoneField.Root>21	);22}
Keep the root controlled or uncontrolled for its complete lifetime. defaultValue and defaultCountry are read once on 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. A typing run undoes as one step; paste, cut, drop, and country changes are independent steps. The last 100 steps are kept, and replacing a controlled value starts a new history.
Paste an international number beginning with + and PhoneField selects its country, then keeps only the national number in the input. If countries excludes that country, the text is preserved and emitted as invalid. Digits beyond the longest possible number for the country are blocked; pass limitMaxLength={false} to allow them. A number is invalid when its detected country differs from the selected one, including countries that share a calling code.

Compose the parts

Pass children to Country to customize any piece. Every part maps to one Base UI Combobox part, takes className, style, and render, and has sensible default content you can replace.

TSX · 44 lines
1import { PhoneField } from "phonefield";2import { getExampleNumber } from "phonefield/utils";34export function ComposedPhone() {5	return (6		<PhoneField.Root defaultCountry="US" className="phone">7			<PhoneField.Label className="phone-label">Phone</PhoneField.Label>8			<PhoneField.Group className="phone-group">9				<PhoneField.Country>10					<PhoneField.CountryTrigger className="phone-trigger">11						<PhoneField.CountryValue>12							{(country) => `${country.flag} ${country.iso2}`}13						</PhoneField.CountryValue>14						<PhoneField.CountryIcon className="phone-trigger-icon" />15					</PhoneField.CountryTrigger>16					<PhoneField.CountryPortal>17						<PhoneField.CountryPositioner sideOffset={8}>18							<PhoneField.CountryPopup className="phone-popup">19								<PhoneField.CountrySearch className="phone-search" />20								<PhoneField.CountryEmpty className="phone-empty" />21								<PhoneField.CountryList className="phone-list">22									{(country) => (23										<PhoneField.CountryItem24											country={country}25											className="phone-item"26										>27											{country.flag} {country.name}28											<PhoneField.CountryItemIndicator />29										</PhoneField.CountryItem>30									)}31								</PhoneField.CountryList>32							</PhoneField.CountryPopup>33						</PhoneField.CountryPositioner>34					</PhoneField.CountryPortal>35				</PhoneField.Country>36				<PhoneField.Input37					className="phone-input"38					placeholder={(country) => getExampleNumber(country.iso2)}39				/>40			</PhoneField.Group>41			<PhoneField.Error className="phone-error" />42		</PhoneField.Root>43	);44}

Custom parts with usePhoneField

Read the value, field state, and countries from any component inside Root, and drive it with setCountry and setNumber. Useful for native selects, drawers, and status text.

TSX · 50 lines
1import { PhoneField, usePhoneField } from "phonefield";2import {3	Select,4	SelectContent,5	SelectItem,6	SelectTrigger,7	SelectValue,8} from "@/components/ui/select";910// Any part can read and drive Root. Here, shadcn's Select replaces Country.11function CountrySelect() {12	const { country, countries, setCountry, state } = usePhoneField();13	const items = countries.map((option) => ({14		value: option.iso2,15		label: `${option.flag} ${option.dialCode}`,16	}));1718	return (19		<Select20			items={items}21			value={country.iso2}22			disabled={state.disabled}23			onValueChange={(iso2) => {24				if (iso2) setCountry(iso2);25			}}26		>27			<SelectTrigger aria-label={`Country: ${country.name}`}>28				<SelectValue />29			</SelectTrigger>30			<SelectContent alignItemWithTrigger={false}>31				{countries.map((option) => (32					<SelectItem key={option.iso2} value={option.iso2}>33						{option.flag} {option.name} ({option.dialCode})34					</SelectItem>35				))}36			</SelectContent>37		</Select>38	);39}4041export function CompactPhone() {42	return (43		<PhoneField.Root defaultCountry="US">44			<PhoneField.Group className="flex gap-2">45				<CountrySelect />46				<PhoneField.Input aria-label="Phone number" />47			</PhoneField.Group>48		</PhoneField.Root>49	);50}

Country subset and preferred countries

countries restricts the list to an ordered subset. preferredCountries pins countries to the top of the full list.

TSX · 21 lines
1import { PhoneField } from "phonefield";23// Only these countries, in this order.4export function NorthAmericaPhone() {5	return (6		<PhoneField.Root countries={["US", "CA", "MX"]}>7			<PhoneField.Country />8			<PhoneField.Input aria-label="Phone number" />9		</PhoneField.Root>10	);11}1213// Every country, with these pinned to the top.14export function PinnedPhone() {15	return (16		<PhoneField.Root preferredCountries={["US", "GB"]}>17			<PhoneField.Country />18			<PhoneField.Input aria-label="Phone number" />19		</PhoneField.Root>20	);21}

Internationalization

lang localizes country names, sorting, search, and the trigger's accessible name. labels translates the search placeholder and empty text, with or without composed parts.

TSX · 23 lines
1import { PhoneField } from "phonefield";2import { getExampleNumber } from "phonefield/utils";34// lang localizes country names, sorting, search, and the trigger label.5// labels translates the picker's search placeholder and empty text.6export function ArgentinaPhone() {7	return (8		<PhoneField.Root lang="es-AR" defaultCountry="AR">9			<PhoneField.Label>Teléfono</PhoneField.Label>10			<PhoneField.Country11				labels={{ search: "Buscar país", empty: "Sin resultados" }}12			/>13			<PhoneField.Input14				placeholder={(country) => getExampleNumber(country.iso2)}15			/>16			<PhoneField.Error>17				{(state) =>18					state.filled ? "Ingresá un número válido." : "Ingresá un número."19				}20			</PhoneField.Error>21		</PhoneField.Root>22	);23}
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 read once, and replacing them can overwrite a user's selection.
Hydration and country names. Names come from Intl.DisplayNames, which can differ between Node and browsers for a few regions. The default picker server-renders only the selected country's name. Rendering every name on the server (a native select, an always-open list) can cause hydration warnings; render those lists on the client.

Styling

Every part is unstyled. Style each one with className; Group is the field shell and carries field state as data attributes.

Recommended

Using Tailwind or class utilities?

Pass className to each part. Style the shell on Group with data-focused:, data-invalid:, and data-disabled:.

Alternative

Using CSS or CSS Modules?

Target the stable data-slot anatomy and the state attributes on Root and Group. Popup selectors must be global because the popup is rendered in a portal.

Present Group as one visual field, but keep Country and Input as separate accessible controls. Label, Group, Description, and Error all carry the field's state attributes. The popup is portaled out of Root: style its parts directly, or read usePhoneField().state in a wrapper when it must react to field state.

Field state

State attributes on Group (and Root, Label, Description, Error) replace manual focus, touched, and validity tracking. className and style also accept a function of PhoneField.State.

TSX · 30 lines
1import { PhoneField } from "phonefield";23// Root is a plain wrapper. Group is the visual shell and mirrors field state4// as data attributes, so no React state is needed.5export function StatefulPhone() {6	return (7		<PhoneField.Root required className="grid gap-1.5">8			<PhoneField.Label>Phone</PhoneField.Label>9			<PhoneField.Group className="flex rounded-lg border data-focused:ring-2 data-invalid:border-red-500 data-disabled:opacity-50">10				<PhoneField.Country />11				<PhoneField.Input className="flex-1 px-3 outline-none" />12			</PhoneField.Group>13			<PhoneField.Error className="text-sm text-red-600" />14		</PhoneField.Root>15	);16}1718// className and style also accept a function of PhoneField.State.19export function StatusPhone() {20	return (21		<PhoneField.Root>22			<PhoneField.Group23				className={(state) => (state.valid ? "phone phone--valid" : "phone")}24			>25				<PhoneField.Country />26				<PhoneField.Input aria-label="Phone number" />27			</PhoneField.Group>28		</PhoneField.Root>29	);30}

Production preset

A reusable shadcn-style wrapper: Label, Description, and Error around a Group with one border and focus ring, and a styled popup composed from the parts.

TSX · 110 lines
1import { CheckIcon, ChevronDownIcon } from "lucide-react";2import { PhoneField } from "phonefield";3import type * as React from "react";4import { cn } from "@/lib/utils";56type PhoneInputProps = Omit<PhoneField.RootProps, "className"> & {7	className?: string;8	label?: React.ReactNode;9	description?: React.ReactNode;10	placeholder?: PhoneField.InputProps["placeholder"];11	inputProps?: Omit<PhoneField.InputProps, "placeholder" | "className">;12};1314export function PhoneInput({15	className,16	label,17	description,18	placeholder,19	inputProps,20	children,21	...props22}: PhoneInputProps) {23	return (24		<PhoneField.Root25			{...props}26			className={cn("grid w-full gap-1.5", className)}27		>28			{label ? (29				<PhoneField.Label className="text-sm font-medium text-foreground">30					{label}31				</PhoneField.Label>32			) : null}33			<PhoneField.Group34				className={cn(35					"flex h-10 w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-background shadow-xs transition-[border-color,box-shadow] duration-150",36					"data-focused:border-ring data-focused:ring-3 data-focused:ring-ring/40",37					"data-invalid:border-destructive data-invalid:ring-destructive/20",38					"data-disabled:cursor-not-allowed data-disabled:opacity-50",39				)}40			>41				<PhoneField.Country>42					<PhoneField.CountryTrigger className="group/trigger flex shrink-0 cursor-default items-center gap-1.5 border-input border-r px-3 text-sm outline-none select-none transition-colors duration-150 hover:bg-accent/60 focus-visible:bg-accent data-popup-open:bg-accent">43						<PhoneField.CountryValue>44							{(country) => (45								<>46									<span aria-hidden className="text-base leading-none">47										{country.flag}48									</span>49									<span className="tabular-nums text-muted-foreground">50										{country.dialCode}51									</span>52								</>53							)}54						</PhoneField.CountryValue>55						<PhoneField.CountryIcon className="text-muted-foreground transition-transform duration-150 ease-out group-data-popup-open/trigger:rotate-180 motion-reduce:transition-none">56							<ChevronDownIcon className="size-3.5" />57						</PhoneField.CountryIcon>58					</PhoneField.CountryTrigger>59					<PhoneField.CountryPortal>60						<PhoneField.CountryPositioner61							className="isolate z-50"62							sideOffset={6}63						>64							<PhoneField.CountryPopup className="flex max-h-[min(22rem,var(--available-height))] w-72 max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-xl ring-1 ring-foreground/10 transition-[transform,opacity] duration-150 ease-out 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:transition-none">65								<div className="border-b p-1.5">66									<PhoneField.CountrySearch className="h-8 w-full rounded-md bg-muted/60 px-2.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/50" />67								</div>68								<PhoneField.CountryEmpty className="px-3 py-6 text-center text-sm text-muted-foreground empty:hidden" />69								<PhoneField.CountryList className="min-h-0 flex-1 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0 [&_[data-preferred]]:mb-1 [&_[data-preferred]]:border-b [&_[data-preferred]]:pb-1">70									{(country) => (71										<PhoneField.CountryItem72											country={country}73											className="flex cursor-default items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"74										>75											<span aria-hidden className="text-base leading-none">76												{country.flag}77											</span>78											<span className="min-w-0 flex-1 truncate">79												{country.name}80											</span>81											<span className="tabular-nums text-muted-foreground">82												{country.dialCode}83											</span>84											<PhoneField.CountryItemIndicator className="text-foreground">85												<CheckIcon className="size-3.5" />86											</PhoneField.CountryItemIndicator>87										</PhoneField.CountryItem>88									)}89								</PhoneField.CountryList>90							</PhoneField.CountryPopup>91						</PhoneField.CountryPositioner>92					</PhoneField.CountryPortal>93				</PhoneField.Country>94				<PhoneField.Input95					aria-label={label ? undefined : "Phone number"}96					{...inputProps}97					placeholder={placeholder}98					className="h-full min-w-0 flex-1 bg-transparent px-3 text-base tabular-nums outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed md:text-sm"99				/>100			</PhoneField.Group>101			{description ? (102				<PhoneField.Description className="text-xs text-muted-foreground">103					{description}104				</PhoneField.Description>105			) : null}106			<PhoneField.Error className="text-xs text-destructive" />107			{children}108		</PhoneField.Root>109	);110}

CSS data slots

The same presentation without Tailwind. Group state attributes drive the shell; data slots target the controls, the error, and the portaled popup.

CSS · 77 lines
1/* Group is the visual shell and carries field state as data attributes. */2[data-slot="phone-field-group"] {3	display: flex;4	height: 2.5rem;5	border: 1px solid #e2e8f0;6	border-radius: 0.5rem;7	overflow: hidden;8	transition:9		border-color 150ms,10		box-shadow 150ms;11}1213[data-slot="phone-field-group"][data-focused] {14	border-color: #0ea5e9;15	box-shadow: 0 0 0 3px rgb(14 165 233 / 0.15);16}1718[data-slot="phone-field-group"][data-invalid] {19	border-color: #ef4444;20}2122[data-slot="phone-field-group"][data-disabled] {23	opacity: 0.5;24}2526[data-slot="phone-field-error"] {27	color: #dc2626;28	font-size: 0.875rem;29}3031[data-slot="phone-field-country-trigger"],32[data-slot="phone-field-input"] {33	border: 0;34	background: transparent;35	outline: none;36	padding-inline: 0.75rem;37}3839[data-slot="phone-field-country-trigger"] {40	display: flex;41	align-items: center;42	gap: 0.5rem;43	border-right: 1px solid #e2e8f0;44}4546[data-slot="phone-field-input"] {47	flex: 1;48	min-width: 0;49}5051/* The popup is portaled out of Root: keep these selectors global. */52[data-slot="phone-field-country-popup"] {53	width: 18rem;54	max-height: min(22rem, var(--available-height));55	display: flex;56	flex-direction: column;57	transform-origin: var(--transform-origin);58	border-radius: 0.75rem;59	background: white;60	box-shadow: 0 20px 40px rgb(15 23 42 / 0.14);61}6263[data-slot="phone-field-country-list"] {64	overflow-y: auto;65}6667[data-slot="phone-field-country-group"][data-preferred] {68	border-bottom: 1px solid #e2e8f0;69}7071[data-slot="phone-field-country-item"][data-highlighted] {72	background: #f1f5f9;73}7475[data-slot="phone-field-country-empty"]:empty {76	display: none;77}

Stable anatomy

Base UI state attributes compose with these slots. For example, style an open trigger with [data-popup-open] and a highlighted country with [data-highlighted]. Field state attributes are listed in the Component API. With render, PhoneField's data-slot replaces the rendered component's own.

phone-field
Root wrapper, with state attributes
phone-field-label
Label for the input
phone-field-group
Visual shell, with state attributes
phone-field-input
Telephone input
phone-field-description
Help text
phone-field-error
Error message, only while invalid
phone-field-hidden-input
FormData payload
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
Country search
phone-field-country-empty
Empty state
phone-field-country-list
Scrollable list
phone-field-country-group
Group; data-preferred when pinned
phone-field-country-item
Country option; data-country
phone-field-country-item-indicator
Selected check mark

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 name on Root to submit one entry with countryIso2 and nationalNumber. Submitting the form marks the field touched, so errors show without a prior blur; resetting it restores an uncontrolled field's initial value. Use form for a form outside Root. A disabled field is not submitted.

TSX · 33 lines
1import { PhoneField } from "phonefield";2import { fromFormData } from "phonefield/utils";3import { Button } from "@/components/ui/button";45export function ContactForm() {6	return (7		<form8			onSubmit={(event) => {9				event.preventDefault();10				const phone = fromFormData(new FormData(event.currentTarget), "phone");11				console.log(phone?.e164, phone?.isValid);12			}}13		>14			{/* Submitting shows the error without a prior blur; reset restores the value. */}15			<PhoneField.Root name="phone" defaultCountry="US" required>16				<PhoneField.Label>Phone</PhoneField.Label>17				<PhoneField.Country />18				<PhoneField.Input />19				<PhoneField.Error />20			</PhoneField.Root>21			<Button type="reset" variant="outline">22				Reset23			</Button>24			<Button type="submit">Continue</Button>25		</form>26	);27}2829// The same helper runs on the server. Never trust client-derived fields.30export function parseSubmittedPhone(formData: FormData) {31	const phone = fromFormData(formData, "phone");32	return phone?.isValid ? phone.e164 : null;33}

Validity states

After blur or submit, the field is invalid when the number is incomplete or invalid, or required and empty. Input gets aria-invalid, Group gets data-invalid, and Error appears. invalid={!!serverError} adds an external error; automatic validation still applies when it is false.

TSX · 25 lines
1import { PhoneField } from "phonefield";23export function ValidatedPhone({ serverError }: { serverError?: string }) {4	return (5		<PhoneField.Root6			name="phone"7			required8			invalid={!!serverError}9			className="space-y-1.5"10		>11			<PhoneField.Label>Phone</PhoneField.Label>12			<PhoneField.Group className="flex rounded-lg border data-focused:ring-2 data-invalid:border-red-500">13				<PhoneField.Country />14				<PhoneField.Input />15			</PhoneField.Group>16			<PhoneField.Description>We'll text you a code.</PhoneField.Description>17			<PhoneField.Error className="text-sm text-red-600">18				{(state) =>19					serverError ??20					(state.filled ? "Enter a valid number." : "Phone is required.")21				}22			</PhoneField.Error>23		</PhoneField.Root>24	);25}
required also sets the native attribute on Input, so the browser blocks an empty submit. The trigger is named after the selected country unless you give it aria-label, aria-labelledby, or an id for your own label. Do not wrap both controls in one Base UI Field: PhoneField provides its own Label, Description, and Error.

Component API

Props, state attributes, and parts for Root, the field parts, Input, and Country, plus the usePhoneField hook.

PhoneField.Root

Owns the value, field state, and form serialization. Renders a plain div wrapper; every other part must be inside it.

PropTypeDefaultDescription
valuePhoneField.InputValue | PhoneField.Value-Controlled source fields. Derived fields are rebuilt.
defaultValuePhoneField.InputValue | PhoneField.Value-Initial uncontrolled value. Read once on mount.
onValueChange(value: PhoneField.Value, details: { reason }) => void-Fires when the country or number changes. reason is "input", "paste", "country", "undo", or "redo".
defaultCountryPhoneField.CountryCode"US" or first availableInitial country when no value is provided.
countriesreadonly PhoneField.CountryCode[]allRestricts the list and international-paste detection to these countries, in this order.
preferredCountriesreadonly PhoneField.CountryCode[]-Pinned to the top of the list in a group with data-preferred.
langPhoneField.Lang"en"Locale for country names, sorting, and search.
namestring-Serializes { countryIso2, nationalNumber } into one FormData entry.
formstring-Associates the serialized value with a form outside Root.
formatOnTypebooleantrueFormats the number as the user types.
limitMaxLengthbooleantrueBlocks digits beyond the longest possible number for the selected country.
disabledbooleanfalseDisables Input and Country. A disabled field is not submitted.
readOnlybooleanfalseMakes Input and Country read-only.
requiredbooleanfalseSets required on Input. An empty field becomes invalid once touched or submitted.
invalidbooleanfalseMarks the field invalid, e.g. invalid={!!serverError}. Automatic validation still applies when false.
classNamestring | (state: PhoneField.State) => string-Classes for the root element, or a function of field state.
styleCSSProperties | (state: PhoneField.State) => CSSProperties-Inline styles, or a function of field state.
renderReactElement | (props, state) => ReactElement<div />Replaces the root element, e.g. render={<fieldset />}.
...divPropsReact.ComponentProps<"div">-Children, ref, events, and ARIA attributes.

Root state attributes

Root and Group mirror PhoneField.State as data attributes, so you can style the field without tracking state. The same object is passed to className and style functions and returned by usePhoneField().state. Boolean attributes are present only when true.

AttributeState keyPresent when
data-countrycountrySelected ISO2 code, e.g. data-country="US".
data-validvalidThe number is complete and valid for the selected country.
data-invalidinvalidTouched and the number is incomplete or invalid, or required and empty. Also set by the invalid prop.
data-filledfilledThe number contains at least one digit.
data-dirtydirtyThe value differs from the value at mount.
data-touchedtouchedFocus has left the field once, or its form was submitted. Cleared by form reset.
data-focusedfocusedFocus is inside the field, including the country popup.
data-disableddisabledThe disabled prop is set.
data-readonlyreadOnlyThe readOnly prop is set.
data-requiredrequiredThe required prop is set.

Field parts

Label, Group, Description, and Error take className and style (static or a function of PhoneField.State), render, and the props of their element. They carry the same state attributes as Root. With render, PhoneField's data-slot replaces the rendered component's own.

PartElementdata-slotBehavior
Label<label>phone-field-labelhtmlFor follows the Input id automatically, including a custom id.
Group<div role="group">phone-field-groupVisual shell for Country and Input. Mirrors Root's state attributes.
Description<p>phone-field-descriptionHelp text, linked to the Input with aria-describedby.
Error<p>phone-field-errorRenders only while invalid; linked with aria-describedby. Children: node or (state) => node. Default: "Enter a valid phone number." or "Enter a phone number."

PhoneField.Input

A Base UI Input bound to Root. Formatting, paste, undo, and length limits are handled for you.

PropTypeDefaultDescription
placeholderstring | (country: PhoneField.Country) => string | undefined-A string, or a function of the selected country. Pair with getExampleNumber.
typeReact.HTMLInputTypeAttribute"tel"Telephone input type.
inputModeReact.HTMLAttributes<HTMLInputElement>["inputMode"]"tel"Requests a telephone keypad on touch devices.
autoCompletestring"tel-national"National-number autofill.
aria-invalidbooleanstate.invalidSet automatically from Root state. Pass a value to override.
disabledbooleanRootInherited from Root; pass a value to override.
readOnlybooleanRootInherited from Root; pass a value to override.
requiredbooleanRootInherited from Root; pass a value to override.
classNamestring | (state) => string-Classes for the input. The function receives Base UI Input state.
renderReactElement | (props, state) => ReactElement<input />Render your design system's input element.
...inputPropsInput.Props-Base UI Input props, ref, events, and ARIA, except value, defaultValue, name, and onValueChange.

PhoneField.Country

A Base UI Combobox.Root whose selection is managed by Root. Without children it renders the full default anatomy, unstyled.

PropTypeDefaultDescription
childrenReact.ReactNodedefault anatomyCompose Country* parts. Omit for a complete, unstyled picker.
openboolean-Controlled popup open state.
defaultOpenbooleanfalseInitial popup open state.
onOpenChange(open: boolean, details) => void-Fires when the popup opens or closes.
modalbooleanfalseLocks page scroll and outside pointer interactions while open.
autoHighlightbooleantrueHighlights the first match while searching.
disabledbooleanRootDisables only the picker. Root disabled always applies.
labels{ search?: string; empty?: ReactNode }English copyDefault text for CountrySearch and CountryEmpty, in the zero-config picker or composed parts. Props on the parts win.
...comboboxPropsCombobox.Root.Props-Other Combobox.Root props. Value, items, and filtering are owned by Root.

Country parts

Each part accepts className and style (static or a function of the part's Base UI state), render, and the props of the Base UI part it wraps. Parts below Portal render outside Root, so style them directly rather than through Root selectors.

PartWrapsdata-slotDefaults
CountryTriggerCombobox.Triggerphone-field-country-triggerChildren: CountryValue + CountryIcon. aria-label "United States (+1)" unless you pass aria-label, aria-labelledby, or id.
CountryValueCombobox.Value- (text only)Children: node or (country) => node. Default "🇺🇸 +1". Renders no element.
CountryIconCombobox.Iconphone-field-country-iconChildren: up/down chevron.
CountryPortalCombobox.Portal-Portals to document.body. Pass container to change.
CountryPositionerCombobox.Positionerphone-field-country-positionerside="bottom" align="start" sideOffset={4}.
CountryPopupCombobox.Popupphone-field-country-popupPopup container.
CountrySearchCombobox.Inputphone-field-country-searchplaceholder and aria-label: labels.search or "Search country". Matches name, ISO2, and dial code.
CountryEmptyCombobox.Emptyphone-field-country-emptyChildren: labels.empty or "No countries found".
CountryListCombobox.Listphone-field-country-listChildren: (country) => node; keys are handled. Default renders CountryItem.
CountryItemCombobox.Itemphone-field-country-itemRequires country. Children: "🇺🇸 United States (+1)". Adds data-country.
CountryItemIndicatorCombobox.ItemIndicatorphone-field-country-item-indicatorRenders only when selected. Children: check mark.

usePhoneField()

Reads and drives the nearest Root from your own parts: a native select, a drawer, or a status message. Must be called inside PhoneField.Root.

FieldTypeDefaultDescription
valuePhoneField.Value-The current canonical value.
statePhoneField.State-Field state: the same keys Root mirrors as data attributes.
countryPhoneField.Country-The selected country.
countriesreadonly PhoneField.Country[]-Available countries, already localized and sorted.
setCountry(iso2: string) => void-Selects a country by ISO2 code. Unknown or unavailable codes are ignored.
setNumber(number: string) => void-Replaces the number as if typed. A + prefix selects the detected country.

Utilities

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

Formatting and utilities

parse() returns libphonenumber's PhoneNumber. String parsing is strict: pass { defaultCountry } for national numbers or { extract: true } for arbitrary text. getExampleNumber() returns a placeholder-ready example.

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

phonefield/utils

Named exports that run on the client and the server. Import only what you use.

ExportTypeDefaultDescription
parse(value, options?)(string | Value, ParseOptions?) => PhoneNumber | undefined-Parse a strict string or Value into libphonenumber's PhoneNumber.
isValid(value, options?)(string | Value, ParseOptions?) => boolean-Validate a strict string, or a Value against 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(lang?)(Lang?) => CountryMap-Return localized, runtime-immutable country metadata.
countriesPhoneField.CountryMapEnglish mapDefault country metadata without locale lookup.
getExampleNumber(iso2, format?)(CountryCode, "national" | "international"?) => string | undefined"national"Example mobile number for a country. Use it as a placeholder.

Migrate from v1 to v2

The value model and submitted payload are unchanged. Country configuration props became composable parts, and field state, labels, errors, and form submit/reset handling are now built in.

Upgrade, then follow the type errors

Removed Country props fail the typecheck, so most steps are found for you. Two steps change behavior without a type error and are flagged below. @base-ui/react must be 1.9 or newer; React 18 remains unsupported. No phone data migration is required.

pnpm add phonefield@^2 @base-ui/react@^1.9
Upgrading from 0.x? Follow the 0.x → v1 guide first, then this one.

Style each part with className

Country no longer takes classNames. Compose the parts you want to style and pass className to each one. Zero-config <PhoneField.Country /> still renders the full picker.

v1 · before
1<PhoneField.Country2  classNames={{3    trigger: "trigger",4    popup: "popup",5    item: "item",6  }}7/>
v2 · after
1<PhoneField.Country>2  <PhoneField.CountryTrigger className="trigger" />3  <PhoneField.CountryPortal>4    <PhoneField.CountryPositioner>5      <PhoneField.CountryPopup className="popup">6        <PhoneField.CountrySearch />7        <PhoneField.CountryEmpty />8        <PhoneField.CountryList>9          {(country) => (10            <PhoneField.CountryItem country={country} className="item" />11          )}12        </PhoneField.CountryList>13      </PhoneField.CountryPopup>14    </PhoneField.CountryPositioner>15  </PhoneField.CountryPortal>16</PhoneField.Country>

Move positioning to CountryPositioner

Every Base UI Positioner prop is available. Defaults are unchanged: side="bottom", align="start", sideOffset={4}.

v1 · before
1<PhoneField.Country2  positioning={{ side: "top", sideOffset: 8 }}3/>
v2 · after
1<PhoneField.CountryPositioner side="top" sideOffset={8}>2  <PhoneField.CountryPopup>…</PhoneField.CountryPopup>3</PhoneField.CountryPositioner>

Replace renderers and icon with children

CountryValue and CountryList take a function of the country. List keys are handled for you. icon becomes the children of CountryIcon.

v1 · before
1<PhoneField.Country2  icon={<ChevronDown />}3  renderCountryValue={(c) => c.dialCode}4  renderCountryItem={(c) => c.name}5/>
v2 · after
1<PhoneField.CountryTrigger>2  <PhoneField.CountryValue>{(c) => c.dialCode}</PhoneField.CountryValue>3  <PhoneField.CountryIcon><ChevronDown /></PhoneField.CountryIcon>4</PhoneField.CountryTrigger>5…6<PhoneField.CountryList>7  {(c) => <PhoneField.CountryItem country={c}>{c.name}</PhoneField.CountryItem>}8</PhoneField.CountryList>

Pass slotProps to the part itself

slotProps.root becomes props on Country (open, onOpenChange, modal…). Every other slot becomes props on its part.

v1 · before
1<PhoneField.Country2  slotProps={{3    root: { modal: true, onOpenChange },4    trigger: { "aria-label": "Country" },5  }}6/>
v2 · after
1<PhoneField.Country modal onOpenChange={onOpenChange}>2  <PhoneField.CountryTrigger aria-label="Country" />3  …4</PhoneField.Country>

Move copy props to labels

inputPlaceholder and noResultsText become Country labels, which also apply to composed parts. Props on CountrySearch and CountryEmpty still win. placeholder is removed: a country is always selected.

v1 · before
1<PhoneField.Country2  inputPlaceholder="Buscar país"3  noResultsText="Sin resultados"4/>
v2 · after
1<PhoneField.Country2  labels={{ search: "Buscar país", empty: "Sin resultados" }}3/>

Replace manual validation wiring with Group and Error

Root tracks touched, focus, and validity; submitting the form reveals errors and reset restores the value. Group is the styled shell with data-invalid, Error renders only while invalid, and both Description and Error are linked with aria-describedby.

v1 · before
1const [touched, setTouched] = useState(false);2const invalid = touched && !phone.isValid;34<label htmlFor="phone">Phone</label>5<PhoneField.Root className={invalid ? "error" : ""}>6  <PhoneField.Country />7  <PhoneField.Input8    id="phone"9    aria-invalid={invalid || undefined}10    aria-describedby="phone-error"11    onBlur={() => setTouched(true)}12  />13</PhoneField.Root>14{invalid && <p id="phone-error">Invalid number</p>}
v2 · after
1<PhoneField.Root required invalid={!!serverError}>2  <PhoneField.Label>Phone</PhoneField.Label>3  <PhoneField.Group className="data-invalid:border-red-500">4    <PhoneField.Country />5    <PhoneField.Input />6  </PhoneField.Group>7  <PhoneField.Error>{serverError}</PhoneField.Error>8</PhoneField.Root>

onValueChange receives change details

A second argument reports why the value changed. Existing one-argument callbacks keep working.

v1 · before
1onValueChange={(value) => save(value)}
v2 · after
1onValueChange={(value, { reason }) => {2  // "input" | "paste" | "country" | "undo" | "redo"3  if (reason !== "country") save(value);4}}

Behavior change

Digits beyond the maximum length are blocked

limitMaxLength now defaults to true: typing or pasting more digits than any number in the country can have is ignored. Opt out to keep v1 behavior.

v1 · before
1<PhoneField.Root>
v2 · after
1<PhoneField.Root limitMaxLength={false}>

Behavior change

The trigger shows flag and dial code

The default trigger content is now "🇺🇸 +1" instead of "🇺🇸 United States (+1)". Its accessible name stays "United States (+1)", so role queries keep working; text queries do not.

v1 · before
1screen.getByText("🇺🇸 United States (+1)");
v2 · after
1screen.getByRole("combobox", { name: "United States (+1)" });2// or restore the old content:3<PhoneField.CountryValue>4  {(c) => `${c.flag} ${c.name} (${c.dialCode})`}5</PhoneField.CountryValue>

Migration checklist

  • @base-ui/react is 1.9 or newer
  • No classNames, slotProps, positioning, or render* props on Country
  • No PhoneField.CountryClassNames, CountrySlotProps, or Render* types
  • CSS targets phone-field-country-search, not the old search slots
  • Manual touched, aria-invalid, and error wiring replaced by Group and Error
  • limitMaxLength={false} added where long input must be kept
  • Tests no longer match the trigger by country name text