Phone input
A phone number with its country, without flags. Grouped as you type in the habit of the country, given back in E.164, with a searchable country list.
One of 6 components in Special input fields
Variants
Details
What Phone input brings along, read from the registry and the source.
- Category
- Special input fields
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/phone-input.tsx
- npm packages
- None
- Languages
- English and German, 11 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PhoneInputProps interface from components/ui/phone-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalPhoneInputVariantselectdetectsplitverify
valueoptionalstringE.164, e.g. "+4915112345678"
defaultValueoptionalstringonValueChangeoptional(e164: string, info: { country: string; national: string; complete: boolean }) => voiddefaultCountryoptionalstringThe country at the start, e.g. "DE"
countriesoptionalPhoneCountry[]The countries on offer, default a list of 19
preferredoptionalstring[]Countries shown first in the list
placeholderoptionalstringonSendCodeoptional(e164: string) => Promise<void> | voidverify: called with the number, resolve when the code is on its way
resendAfteroptionalnumberverify: seconds before the code can be sent again. Default 30.
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalboolean
Built-in texts
Phone input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 11 texts
| Key | English | German |
|---|---|---|
| country | English Country | German Land |
| search | English Search a country or code | German Land oder Vorwahl suchen |
| empty | English No country found. Try the dialling code. | German Kein Land gefunden. Versuch die Vorwahl. |
| valid | English Number complete | German Nummer vollständig |
| send | English Send code | German Code senden |
| sending | English Sending… | German Wird gesendet… |
| resend | English Send again | German Erneut senden |
| resendIn | English Send again in | German Erneut senden in |
| sent | English Code sent to | German Code gesendet an |
| change | English Change number | German Nummer ändern |
| unknown | English Start with + and the country code | German Mit + und Ländervorwahl beginnen |
Accessibility
Read from the source of the component.
- Phone input handles the keys Escape, Enter, Arrow up, Arrow down and Space itself.
- Sets aria-activedescendant, aria-controls, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles combobox, listbox, option and status.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 438 lines
'use client';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CONTROL_HEIGHT, SuccessMark, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, revealOption, unfold } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';
/*
* A phone number with its country. No flags: a flag stands for a state, not
* a language or a dialling code, and it is a picture someone has to draw.
* The country shows as its two letter code and dialling code. The number is
* grouped as you type, in the habit of the country, and the value is given
* back in E.164 (+4915112345678). Four looks: country and number in one box,
* one field that finds the country from the + prefix, two separate fields for
* long forms, and a field with a send code button and a resend countdown.
*/
const TEXTS = {
en: {
country: 'Country', search: 'Search a country or code', empty: 'No country found. Try the dialling code.', valid: 'Number complete',
send: 'Send code', sending: 'Sending…', resend: 'Send again', resendIn: 'Send again in', sent: 'Code sent to', change: 'Change number', unknown: 'Start with + and the country code',
},
de: {
country: 'Land', search: 'Land oder Vorwahl suchen', empty: 'Kein Land gefunden. Versuch die Vorwahl.', valid: 'Nummer vollständig',
send: 'Code senden', sending: 'Wird gesendet…', resend: 'Erneut senden', resendIn: 'Erneut senden in', sent: 'Code gesendet an', change: 'Nummer ändern', unknown: 'Mit + und Ländervorwahl beginnen',
},
};
type Texts = (typeof TEXTS)['en'];
export interface PhoneCountry {
/** ISO 3166 code, e.g. "DE" */
code: string;
/** Dialling code without +, e.g. "49" */
dial: string;
name: { en: string; de: string };
/** Grouping of the national number, # is a digit */
pattern: string;
/** Fewest digits of a complete national number */
min: number;
}
export const PHONE_COUNTRIES: PhoneCountry[] = [
{ code: 'DE', dial: '49', name: { en: 'Germany', de: 'Deutschland' }, pattern: '### #########', min: 10 },
{ code: 'AT', dial: '43', name: { en: 'Austria', de: 'Österreich' }, pattern: '### ########', min: 10 },
{ code: 'CH', dial: '41', name: { en: 'Switzerland', de: 'Schweiz' }, pattern: '## ### ## ##', min: 9 },
{ code: 'NL', dial: '31', name: { en: 'Netherlands', de: 'Niederlande' }, pattern: '# ## ## ## ##', min: 9 },
{ code: 'BE', dial: '32', name: { en: 'Belgium', de: 'Belgien' }, pattern: '### ## ## ##', min: 9 },
{ code: 'FR', dial: '33', name: { en: 'France', de: 'Frankreich' }, pattern: '# ## ## ## ##', min: 9 },
{ code: 'GB', dial: '44', name: { en: 'United Kingdom', de: 'Vereinigtes Königreich' }, pattern: '#### ######', min: 10 },
{ code: 'IE', dial: '353', name: { en: 'Ireland', de: 'Irland' }, pattern: '## ### ####', min: 9 },
{ code: 'ES', dial: '34', name: { en: 'Spain', de: 'Spanien' }, pattern: '### ### ###', min: 9 },
{ code: 'PT', dial: '351', name: { en: 'Portugal', de: 'Portugal' }, pattern: '### ### ###', min: 9 },
{ code: 'IT', dial: '39', name: { en: 'Italy', de: 'Italien' }, pattern: '### ### ####', min: 9 },
{ code: 'DK', dial: '45', name: { en: 'Denmark', de: 'Dänemark' }, pattern: '## ## ## ##', min: 8 },
{ code: 'SE', dial: '46', name: { en: 'Sweden', de: 'Schweden' }, pattern: '## ### ## ##', min: 9 },
{ code: 'NO', dial: '47', name: { en: 'Norway', de: 'Norwegen' }, pattern: '### ## ###', min: 8 },
{ code: 'PL', dial: '48', name: { en: 'Poland', de: 'Polen' }, pattern: '### ### ###', min: 9 },
{ code: 'US', dial: '1', name: { en: 'United States', de: 'Vereinigte Staaten' }, pattern: '(###) ###-####', min: 10 },
{ code: 'CA', dial: '1', name: { en: 'Canada', de: 'Kanada' }, pattern: '(###) ###-####', min: 10 },
{ code: 'AU', dial: '61', name: { en: 'Australia', de: 'Australien' }, pattern: '### ### ###', min: 9 },
{ code: 'JP', dial: '81', name: { en: 'Japan', de: 'Japan' }, pattern: '## #### ####', min: 10 },
];
const digitsOf = (s: string) => s.replace(/\D/g, '');
/** Digits for the example in the placeholder */
const SAMPLE = '1512345678901';
const maxDigits = (c: PhoneCountry) => (c.pattern.match(/#/g) ?? []).length;
/** Groups digits after the pattern of a country, as far as they go */
export function formatNational(digits: string, c: PhoneCountry) {
let out = '';
let i = 0;
for (const ch of c.pattern) {
if (i >= digits.length) break;
if (ch === '#') out += digits[i++];
else out += ch;
}
// Leftover digits beyond the pattern are kept, not dropped
return (out + digits.slice(i)).replace(/[\s(-]+$/, '');
}
/** The country whose dialling code starts a number in international form, longest match first */
export function detectCountry(international: string, countries = PHONE_COUNTRIES, prefer?: string) {
const d = digitsOf(international);
const hits = countries.filter((c) => d.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length);
if (!hits.length) return null;
const best = hits.filter((c) => c.dial === hits[0].dial);
return best.find((c) => c.code === prefer) ?? best[0];
}
export type PhoneInputVariant = 'select' | 'detect' | 'split' | 'verify';
export interface PhoneInputProps extends FieldOwnProps {
variant?: PhoneInputVariant;
/** E.164, e.g. "+4915112345678" */
value?: string;
defaultValue?: string;
onValueChange?: (e164: string, info: { country: string; national: string; complete: boolean }) => void;
/** The country at the start, e.g. "DE" */
defaultCountry?: string;
/** The countries on offer, default a list of 19 */
countries?: PhoneCountry[];
/** Countries shown first in the list */
preferred?: string[];
placeholder?: string;
/** verify: called with the number, resolve when the code is on its way */
onSendCode?: (e164: string) => Promise<void> | void;
/** verify: seconds before the code can be sent again. Default 30. */
resendAfter?: number;
size?: ControlSize;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/** Two letters in a quiet box instead of a flag */
function CountryCode({ code, className }: { code: string; className?: string }) {
return <span aria-hidden className={cn('inline-grid h-5 min-w-7 place-items-center rounded-[5px] bg-muted px-1 text-[10.5px] font-semibold tracking-[0.04em] text-muted-foreground', className)}>{code}</span>;
}
/** The searchable list of countries */
function CountryList({ countries, current, preferred, onPick, onClose, t, locale, id }: { countries: PhoneCountry[]; current: PhoneCountry; preferred: string[]; onPick: (c: PhoneCountry) => void; onClose: () => void; t: Texts; locale: 'en' | 'de'; id: string }) {
const [q, setQ] = useState('');
const sorted = useMemo(() => {
const pref = preferred.map((p) => countries.find((c) => c.code === p)).filter(Boolean) as PhoneCountry[];
const rest = countries.filter((c) => !preferred.includes(c.code)).sort((a, b) => a.name[locale].localeCompare(b.name[locale], locale));
return [...pref, ...rest];
}, [countries, preferred, locale]);
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const shown = q.trim() ? sorted.filter((c) => norm(`${c.name[locale]} ${c.code} +${c.dial}`).includes(norm(q.trim()).replace(/^00/, '+'))) : sorted;
const [active, setActive] = useState(() => Math.max(0, shown.indexOf(current)));
const searchRef = useRef<HTMLInputElement>(null);
// The panel is hidden until it is placed, so autofocus would miss it: focus once it shows
useEffect(() => {
const id = requestAnimationFrame(() => requestAnimationFrame(() => searchRef.current?.focus()));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
if (active >= shown.length) setActive(Math.max(0, shown.length - 1));
}, [shown.length, active]);
useEffect(() => {
revealOption(`${id}-${active}`);
}, [active, id]);
return (
<div className="w-[17.5rem] max-w-[calc(100vw-16px)]">
<style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
<div className="flex h-11 items-center gap-2 border-b border-border px-3">
<Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
<input
ref={searchRef}
role="combobox"
aria-expanded
aria-controls={`${id}-list`}
aria-activedescendant={shown.length ? `${id}-${active}` : undefined}
aria-label={t.search}
placeholder={t.search}
value={q}
onChange={(e) => {
setQ(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % Math.max(1, shown.length));
} else if (e.key === 'Enter' && shown[active]) {
e.preventDefault();
onPick(shown[active]);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
className="h-full w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13px]"
/>
</div>
<div id={`${id}-list`} role="listbox" aria-label={t.country} className="kit-scrollbar relative max-h-64 overflow-y-auto overscroll-contain p-1.5">
<ActiveMarker id={shown.length ? `${id}-${active}` : undefined} version={`${q}|${shown.length}`} />
{shown.map((c, i) => (
<div
key={c.code}
id={`${id}-${i}`}
role="option"
aria-selected={c === current}
onPointerMove={() => setActive(i)}
onPointerDown={(e) => e.preventDefault()}
onClick={() => onPick(c)}
style={{ '--i': i } as React.CSSProperties}
className={cn('kitfx-select-row relative flex min-h-11 cursor-default items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground sm:min-h-9', i === preferred.length - 1 && preferred.length > 0 && !q && 'mb-1 after:absolute after:inset-x-1 after:-bottom-[3px] after:h-px after:bg-border after:content-[""]')}
>
<CountryCode code={c.code} />
<span className="min-w-0 flex-1 truncate">{c.name[locale]}</span>
<span className="shrink-0 text-xs tabular-nums text-subtle-foreground">+{c.dial}</span>
{c === current && <Icon name="check" size={14} strokeWidth={2} className="shrink-0" />}
</div>
))}
{shown.length === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
</div>
</div>
);
}
function PhoneBox(props: Omit<PhoneInputProps, keyof FieldOwnProps> & { fieldError?: boolean }) {
const {
variant = 'select', value, defaultValue, onValueChange, defaultCountry = 'DE', countries = PHONE_COUNTRIES, preferred = ['DE', 'AT', 'CH'], placeholder, onSendCode, resendAfter = 30,
size = 'md', name, id, disabled, required, invalid, labels, className,
} = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const c = useControl({ id, invalid, disabled, required });
const start = value ?? defaultValue ?? '';
const startCountry = (start.startsWith('+') && detectCountry(start, countries, defaultCountry)) || countries.find((x) => x.code === defaultCountry) || countries[0];
const [country, setCountry] = useState<PhoneCountry>(startCountry);
const [national, setNational] = useState(() => (start.startsWith('+') ? digitsOf(start).slice(startCountry.dial.length) : digitsOf(start)));
// detect: the whole number as typed, with + and dialling code
const [raw, setRaw] = useState(() => (start ? `+${startCountry.dial} ${formatNational(digitsOf(start).slice(startCountry.dial.length), startCountry)}` : ''));
const [open, setOpen] = useState(false);
const mounted = useMounted();
const boxRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const listId = useId();
const refs = useRef([triggerRef, panelRef]).current;
const close = useCallback(() => setOpen(false), []);
useDismiss(open, close, refs);
useShake(boxRef, c.invalid);
const detected = variant === 'detect' ? (raw.trim().startsWith('+') ? detectCountry(raw, countries, defaultCountry) : null) : country;
const nat = variant === 'detect' ? (detected ? digitsOf(raw).slice(detected.dial.length) : '') : national;
const active = detected ?? country;
const complete = Boolean(detected) && nat.length >= active.min && nat.length <= maxDigits(active);
const e164 = detected && nat ? `+${active.dial}${nat}` : '';
const report = (cn_: PhoneCountry, n: string) => onValueChange?.(n ? `+${cn_.dial}${n}` : '', { country: cn_.code, national: n, complete: n.length >= cn_.min && n.length <= maxDigits(cn_) });
const typeNational = (text: string) => {
let d = digitsOf(text);
// A pasted number in international form picks its country
if (/^\s*(\+|00)/.test(text)) {
const found = detectCountry(text.replace(/^\s*00/, '+'), countries, country.code);
if (found) {
setCountry(found);
d = digitsOf(text.replace(/^\s*00/, '')).slice(found.dial.length);
setNational(d.slice(0, maxDigits(found) + 2));
report(found, d);
return;
}
}
// A leading trunk zero (0151…) is dropped, the dialling code replaces it
d = d.replace(/^0+/, '').slice(0, maxDigits(country) + 2);
setNational(d);
report(country, d);
};
const pickCountry = (next: PhoneCountry) => {
setCountry(next);
setOpen(false);
report(next, national);
requestAnimationFrame(() => inputRef.current?.focus());
};
/* ---------- verify: send a code and wait ---------- */
const [stage, setStage] = useState<'idle' | 'sending' | 'sent'>('idle');
const [left, setLeft] = useState(0);
useEffect(() => {
if (left <= 0) return;
const tm = window.setTimeout(() => setLeft((s) => s - 1), 1000);
return () => window.clearTimeout(tm);
}, [left]);
const send = async () => {
if (!complete || stage === 'sending') return;
setStage('sending');
try {
await onSendCode?.(e164);
setStage('sent');
setLeft(resendAfter);
} catch {
setStage('idle');
}
};
const numberInput = (cls?: string, ariaLabel?: string) => (
<input
ref={inputRef}
id={c.id}
type="tel"
inputMode="tel"
autoComplete={variant === 'detect' ? 'tel' : 'tel-national'}
aria-invalid={c['aria-invalid']}
aria-describedby={c['aria-describedby']}
aria-label={ariaLabel}
disabled={c.disabled || (variant === 'verify' && stage === 'sent')}
required={c.required}
placeholder={placeholder ?? (variant === 'detect' ? `+${active.dial} ${formatNational(SAMPLE.slice(0, active.min), active)}` : formatNational(SAMPLE.slice(0, active.min), active))}
value={variant === 'detect' ? raw : formatNational(national, country)}
onChange={(e) => {
if (variant === 'detect') {
const v = e.target.value;
const plus = v.trim().startsWith('+') || v.trim().startsWith('00');
const d = digitsOf(v.replace(/^\s*00/, ''));
const found = plus ? detectCountry(`+${d}`, countries, defaultCountry) : null;
setRaw(plus ? (found ? `+${found.dial} ${formatNational(d.slice(found.dial.length), found)}`.trimEnd() : `+${d}`) : v);
if (found) report(found, d.slice(found.dial.length));
} else typeNational(e.target.value);
}}
className={cn('h-full w-0 min-w-0 flex-1 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed', cls)}
/>
);
const trailing = complete && (
<span className="me-3 flex shrink-0 items-center" title={t.valid}>
<SuccessMark />
<span className="sr-only">{t.valid}</span>
</span>
);
const trigger = (cls: string) => (
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`${t.country}: ${country.name[locale]} +${country.dial}`}
disabled={c.disabled || (variant === 'verify' && stage === 'sent')}
onClick={() => setOpen(!open)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault();
setOpen(true);
}
}}
className={cn('flex shrink-0 items-center gap-1.5 outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none', cls)}
>
<CountryCode key={country.code} code={country.code} className="transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0" />
<span className="tabular-nums text-foreground">+{country.dial}</span>
<Icon name="chevron-down" size={13} className={cn('text-subtle-foreground transition-[rotate] duration-300', open && 'rotate-180')} />
</button>
);
const panel = mounted && open && (
<Floating anchor={variant === 'split' ? triggerRef : boxRef} side="bottom" align="start" offset={6} ref={panelRef}>
{(pos) => (
<div className={cn('overflow-hidden', panelSurface, unfold(pos?.side).className)}>
<CountryList countries={countries} current={country} preferred={preferred} onPick={pickCountry} onClose={() => { setOpen(false); triggerRef.current?.focus(); }} t={t} locale={locale} id={listId} />
</div>
)}
</Floating>
);
const hidden = name ? <input type="hidden" name={name} value={e164} /> : null;
/* ---------- detect ---------- */
if (variant === 'detect') {
return (
<>
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
<span className="ms-3 grid w-8 shrink-0 place-items-center">
{detected ? <CountryCode key={detected.code} code={detected.code} className="transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] starting:scale-50 starting:opacity-0" /> : <Icon name="phone" size={16} className="text-subtle-foreground" />}
</span>
{numberInput('ps-2 pe-3')}
{trailing}
</div>
<span aria-live="polite" className="sr-only">{detected ? detected.name[locale] : ''}</span>
{raw.trim() && !raw.trim().startsWith('+') && <p className="mt-1.5 text-[13px] text-muted-foreground">{t.unknown}</p>}
{hidden}
</>
);
}
/* ---------- split: two fields ---------- */
if (variant === 'split') {
return (
<div ref={boxRef} className={cn('grid w-full grid-cols-[auto_minmax(0,1fr)] gap-2', className)}>
{trigger(cn('h-11 rounded-lg px-3 text-base sm:text-sm', size === 'lg' && 'h-12', controlBox))}
<div data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex items-center', CONTROL_HEIGHT[size], controlBox)}>
{numberInput('ps-3.5 pe-3')}
{trailing}
</div>
{panel}
{hidden}
</div>
);
}
/* ---------- select and verify: country and number in one box ---------- */
return (
<div className={cn('grid w-full gap-2', className)}>
<div className="flex w-full gap-2 max-sm:flex-col">
<div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex min-w-0 items-center overflow-hidden max-sm:w-full sm:flex-1', CONTROL_HEIGHT[size], controlBox)}>
{trigger('h-full border-e border-border ps-3 pe-2.5')}
{numberInput('ps-3 pe-3')}
{trailing}
</div>
{variant === 'verify' && (
<button
type="button"
onClick={stage === 'sent' ? (left > 0 ? undefined : send) : send}
disabled={!complete || stage === 'sending' || (stage === 'sent' && left > 0)}
className={cn(
'relative inline-flex h-11 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-lg px-4 text-sm font-medium outline-none transition-[background-color,color,opacity,scale] duration-200 active:scale-[0.97] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_30%,transparent)] disabled:cursor-not-allowed',
stage === 'sent' ? 'border border-border-strong bg-background text-foreground disabled:text-muted-foreground' : 'bg-primary text-primary-foreground disabled:opacity-50',
)}
>
{stage === 'sending' && <Icon name="loader" size={15} className="animate-spin motion-reduce:animate-none" />}
{stage === 'idle' ? t.send : stage === 'sending' ? t.sending : left > 0 ? `${t.resendIn} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : t.resend}
{/* The countdown drains as a thin line along the bottom */}
{stage === 'sent' && left > 0 && <span aria-hidden className="absolute inset-x-0 bottom-0 h-[2px] origin-left bg-primary transition-[scale] duration-1000 ease-linear" style={{ scale: `${left / resendAfter} 1` }} />}
</button>
)}
</div>
{variant === 'verify' && stage === 'sent' && (
<p role="status" className="flex flex-wrap items-center gap-x-2 text-[13px] text-muted-foreground transition-[opacity,translate] duration-300 starting:-translate-y-1 starting:opacity-0">
<Icon name="check-circle" size={15} className="text-success" />
{t.sent} <span className="font-medium tabular-nums text-foreground">+{country.dial} {formatNational(national, country)}</span>
<button type="button" onClick={() => { setStage('idle'); setLeft(0); requestAnimationFrame(() => inputRef.current?.focus()); }} className="inline-flex min-h-11 items-center font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:underline sm:min-h-0">
{t.change}
</button>
</p>
)}
{panel}
{hidden}
</div>
);
}
/**
* A phone number with its country, grouped as you type, given back in E.164.
* <PhoneInput label="Mobile" defaultCountry="DE" />
* <PhoneInput variant="detect" label="Phone" />
* <PhoneInput variant="split" label="Phone" preferred={['DE', 'AT', 'CH']} />
* <PhoneInput variant="verify" label="Mobile" onSendCode={(n) => api.sendCode(n)} />
*/
export function PhoneInput({ label, hint, error, optional, labelAction, className, ...rest }: PhoneInputProps) {
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <PhoneBox {...rest} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />);
}Frequent questions
How do I install Phone input?
With aiw add @ai-website/phone-input. The command puts components/ui/phone-input.tsx into your project, the code is yours from then on. It brings along icon, input, select and popover. It needs no further npm packages.
Which variants does Phone input have?
4 variants, Country and number, Finds the country, Two fields and Send a code. You pick one with the variant prop, for example variant="split".
Can I translate the texts of Phone input?
Yes. Phone input carries 11 texts of its own in English and German, such as “Country”, “Search a country or code” and “No country found. Try the dialling code.”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Phone input?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Phone input with aiw_components and install it with aiw_add @ai-website/phone-input. Name the variant in your prompt, for example variant="detect", and the assistant writes your content straight into the props.
Can I adjust Phone input without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching PhoneInput code to copy.