Skip to content
UI Kit
Accent color

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

4 variants

Variants

Country and number

Country and number in one box. The list searches names, codes and dialling codes, a leading 0 is dropped, a pasted +41 picks Switzerland, a complete number draws a check.

Open in Studio

Finds the country

One field for people who type the number in full. The + and the dialling code set the country, its code springs in at the start, and the rest groups itself.

Open in Studio

Two fields

Dialling code and number side by side as two controls, for long forms where every field stands on its own. The favourites lead the list.

Open in Studio

Send a code

A send button that wakes up once the number is complete, a short wait, then a countdown line before it can send again. Here the code field follows and confirms the phone.

Open in Studio

Details

What Phone input brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/phone-input.tsx
Brings along
utilskiticoninputselectpopover
npm packages
None
Languages
English and German, 11 texts of its own

Props

The PhoneInputProps interface from components/ui/phone-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • variantoptional
    PhoneInputVariant

    selectdetectsplitverify

  • valueoptional
    string

    E.164, e.g. "+4915112345678"

  • defaultValueoptional
    string
  • onValueChangeoptional
    (e164: string, info: { country: string; national: string; complete: boolean }) => void
  • defaultCountryoptional
    string

    The country at the start, e.g. "DE"

  • countriesoptional
    PhoneCountry[]

    The countries on offer, default a list of 19

  • preferredoptional
    string[]

    Countries shown first in the list

  • placeholderoptional
    string
  • onSendCodeoptional
    (e164: string) => Promise<void> | void

    verify: called with the number, resolve when the code is on its way

  • resendAfteroptional
    number

    verify: seconds before the code can be sent again. Default 30.

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean

Built-in texts

Phone input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 11 texts
countryEnglish CountryGerman Land
searchEnglish Search a country or codeGerman Land oder Vorwahl suchen
emptyEnglish No country found. Try the dialling code.German Kein Land gefunden. Versuch die Vorwahl.
validEnglish Number completeGerman Nummer vollständig
sendEnglish Send codeGerman Code senden
sendingEnglish Sending…German Wird gesendet…
resendEnglish Send againGerman Erneut senden
resendInEnglish Send again inGerman Erneut senden in
sentEnglish Code sent toGerman Code gesendet an
changeEnglish Change numberGerman Nummer ändern
unknownEnglish Start with + and the country codeGerman 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
components/ui/phone-input.tsx
'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.

Often searched

Jump to

Copied to the clipboard