Skip to content

Phone Input

Country selector, live formatting as the user types, and storing E.164 no matter what shape it arrived in.

Also called Tel Input, Country Code Input — in this system all of them are Phone Input.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Stored as +447400123456

We only use this for delivery updates.

The format follows the country

Switching the country re-masks the same digits. The mask is a hint about grouping, never a validation rule — numbering plans change more often than anyone updates a regex.

Stored as +447400123456

Open the country selector and watch the same digits regroup.

Display vs. storage

What the user reads and what the database holds are two different strings, and only one of them is dialable.

Shown
🇬🇧 +44 7400 123456
Stored
+447400123456

Paste has to work

Every one of these is a real thing people paste from a contact card. All of them should land as the same stored value.

+44 7400 123456→+447400123456
(0)7400 123456→+447400123456
07400-123-456→+447400123456
+44 (0) 7400 123456→+447400123456

Sizes

The country selector keeps a readable dial code at every size. Reducing it to a flag alone removes the one part users actually verify.

Stored as +447400123456

Stored as +447400123456

Stored as +447400123456

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Stored as +447400123456

Empty

Stored as +447400123456

Filled

Stored as +447400123456

Error

Stored as +447400123456

Verified

Stored as +447400123456

Unformatted

Stored as +447400123456

Small

Stored as +447400123456

Large
Germany+49
Country row

Anatomy

Every part, every measurement, and the reason it is that number.

Stored as +447400123456

We only use this for delivery updates.

One field, two controls: a country button that owns the dial code and a tel input that owns the digits. They share a border and a focus ring.

  1. Country buttonFlag + dial code + chevron

    The dial code is the part users verify, so it must stay visible. A flag alone is ambiguous — several countries share +1 and several share a flag at 16px.

  2. Divider1px, inset 6px

    Separates two targets inside one field. Inset from the top and bottom so it reads as a seam rather than a border.

  3. NumberMonospace, tabular

    Monospace so the groups stay aligned as digits are typed and deleted, and so 1 and l cannot be confused when the number is read back aloud.

  4. PlaceholderA real example number

    The country’s own example, not "Enter phone number". It teaches the expected grouping without a single word of instruction.

  5. Live maskApplied on input

    Groups the digits as they are typed. Must never block a character it does not expect — numbering plans change and the mask will be wrong for somebody.

  6. Shared focus ringAround the whole field

    One value, one field, one ring. Two separate rings makes it look like two questions.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-inset—Field fill
--ds-border-interactive—Idle border
--ds-border-subtle—Seam between the two controls
--ds-accent—Focus border
--ds-accent-subtle—Focus halo and selected country
--ds-fg-muted—Placeholder, chevron, dial codes in the list
--ds-danger-border—Invalid number

Spacing

TokenValueUsed for
country paddingCountry button

Radius

TokenValueUsed for
--radius-mdField corners

Typography

TokenValueUsed for
font-mono—The number, so groups stay aligned

Motion

TokenValueUsed for
--duration-fastCountry hover and focus transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthMax widthWhen to use
Small32px0 10px13px13rem—Dense CRM forms and table filters.
Medium36px0 10px15px15rem—The default.
Large44px0 12px16px16rem—Sign-up and checkout, especially on touch.
Country button———5rem—Wide enough for a four-character dial code plus a flag and a chevron without truncating.
Country listmax 256px———18remSearchable past about twenty entries — nobody scrolls to Zimbabwe.

Do

display: 7400 123456
stored: +447400123456
Store E.164, alwaysIt is the only format a telephony API accepts without guessing. What the user typed is a display concern; what you store has to be dialable.
profile.country→geo-IP→navigator.language
Default the country from the user, not the browserA saved profile country beats an IP guess, which beats the browser locale. Getting it right removes an interaction from the most abandoned form in most products.
"+44 (0) 7400-123-456" → +447400123456
Accept any paste and normalise itContact cards contain brackets, spaces, dots and a leading zero after the country code. All of them are the same number and all of them should just work.
type="tel" inputmode="tel"
autocomplete="tel-national"
Use type="tel" with inputmode="tel"It gives the numeric keypad with the symbols phone numbers actually use, and it does not strip leading zeros the way type="number" does.

Don't

07400123456 → 7400123456 → undialable
Do not use type="number"It strips the leading zero most national formats depend on, rejects the plus sign, and puts a spinner on a value that has no order.
maxLength on a mask → a valid number that cannot be entered
Do not block characters the mask does not expectNumbering plans change and your mask is out of date for someone right now. Format loosely, validate on submit, and never swallow a keystroke.
7400 123456
Do not show a flag with no dial codeSeveral countries share +1, several flags are indistinguishable at 16px, and a flag alone is a political statement in some territories. The dial code is the unambiguous part.
/^\d{10}$/ → rejects most of the world
Do not validate against a regex you wroteNational numbering plans are large, inconsistent and constantly revised. Use a maintained library, or validate only that the number is plausibly long enough.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.5Identify Input PurposeAA2.1.1KeyboardA3.3.1Error IdentificationA3.3.3Error SuggestionAA4.1.2Name, Role, ValueA

Contrast

  • The dial code owes 4.5:1 — it is part of the value, not decoration.
  • The seam between the country button and the input must reach 3:1: it is the boundary between two separate targets.
  • Flags are decorative and aria-hidden, because emoji rendering varies wildly and cannot be relied on to convey anything.
  • The example placeholder owes 4.5:1 like any other text.

Keyboard

TabReaches the country button, then the number field. Two controls, two stops.
Enter / Space / ↓On the country button, opens the list and focuses the current country.
A–ZTypeahead in the country list, matching the country name — not the dial code, which nobody remembers.
EscCloses the country list and returns focus to the button.
⌘ / Ctrl + VPastes and normalises, including switching the country when a dial code is present.

Screen readers

  • Announce the selected country and code when it changes: "United Kingdom, plus four four".
  • Read the example format via aria-describedby before the user types. Discovering the expected shape through an error is the worst possible order.
  • Do not announce the mask as it applies. A live region firing on every keystroke while a number regroups is unusable.

Focus & touch

  • The two controls share one visible focus ring around the whole field, drawn with focus-within, plus an inner ring on whichever control has focus. Selecting a country returns focus to the country button, not to the number field — the user may want to check what they picked.
  • inputmode="tel" gives the keypad with * and #, which a plain numeric pad lacks. The country list should be a full-screen sheet with a search field on a phone — a floating list of two hundred countries under an on-screen keyboard is unusable. Field height goes to 44px, and the country button needs its own 44px target.
AttributeApplied toNotes
autocomplete="tel-national"The number fieldWith autocomplete="tel-country-code" on the country control. Split tokens are what let a browser fill both halves correctly.
aria-labelThe country buttonMust include the country and the code: "Country code: United Kingdom +44". A flag has no accessible name.
role="listbox" / "option"The country listWith aria-selected on the current country.
aria-describedbyThe number fieldPoints at the example format, so the expected shape is read before typing rather than discovered by failing.
aria-invalidThe number fieldOn a validation failure, paired with a message naming the country: "That number is too short for the United Kingdom."

Code

Example usage

tsx
1import { PhoneInput } from '@/ui/Input'23<Field label="Mobile number" description="We only use this for delivery updates.">4  <PhoneInput5    value={phone}                   // ALWAYS E.164: "+447400123456"6    onChange={setPhone}7    defaultCountry={user.country ?? geo.country ?? 'US'}8  />9</Field>1011// Display and storage are different strings. Only one of them is dialable.12function toDisplay(e164: string, country: Country) {13  const national = e164.slice(country.dial.length)14  return applyMask(national, country.format)15}1617// Paste is the case that decides whether the field feels good. Detect the18// dial code, switch the country, strip separators, keep going.19function onPaste(e: React.ClipboardEvent) {20  const text = e.clipboardData.getData('text')21  e.preventDefault()22  const match = COUNTRIES.find((c) => text.replace(/\s/g, '').startsWith(c.dial))23  if (match) setCountry(match)24  setDigits(text.replace(/\D/g, ''))25}2627// Validate with a maintained library, never a hand-written regex. National28// numbering plans are large, inconsistent, and revised more often than any29// regex in your codebase.30import { parsePhoneNumber } from 'libphonenumber-js'31const parsed = parsePhoneNumber(raw, country.iso)32const valid = parsed?.isValid() ?? false33const e164 = parsed?.format('E.164')

Framework-free HTML

html
<div class="ds-field">
  <label for="phone">Mobile number</label>
  <p id="phone-hint">For example, 7400 123456</p>

  <div class="ds-phone">
    <!-- A flag has no accessible name. The label carries both parts. -->
    <button
      type="button"
      class="ds-phone__country"
      aria-label="Country code: United Kingdom +44"
      aria-haspopup="listbox"
      aria-expanded="false"
      autocomplete="tel-country-code"
    >
      <span aria-hidden="true">🇬🇧</span>
      <span>+44</span>
      <svg aria-hidden="true">…</svg>
    </button>

    <span class="ds-phone__seam" aria-hidden="true"></span>

    <!-- tel, never number: number strips the leading zero. -->
    <input
      id="phone"
      type="tel"
      inputmode="tel"
      autocomplete="tel-national"
      aria-describedby="phone-hint"
      placeholder="7400 123456"
    />
  </div>

  <!-- What actually gets submitted. -->
  <input type="hidden" name="phone" value="+447400123456" />
</div>

CSS

css
.ds-phone {
  display: flex;
  align-items: stretch;
  block-size: 36px;
  border: 1px solid var(--ds-border-interactive);
  border-radius: var(--radius-md);
  background: var(--ds-surface-inset);
}

/* One value, one field, one ring. Two rings reads as two questions. */
.ds-phone:focus-within {
  border-color: var(--ds-accent);
  box-shadow: 0 0 0 3px var(--ds-accent-subtle);
}

.ds-phone__country {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
  min-inline-size: 5rem;             /* fits +XXX plus a flag and a chevron */
  padding-inline: 10px 8px;
  font-variant-numeric: tabular-nums;
}

/* Inset so it reads as a seam between two targets, not as a border. */
.ds-phone__seam {
  inline-size: 1px;
  margin-block: 6px;
  background: var(--ds-border-subtle);
}

.ds-phone input {
  flex: 1;
  min-inline-size: 0;
  padding-inline: 10px;
  border: 0;
  background: none;
  /* Groups stay aligned as digits are typed and deleted. */
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.ds-phone input::placeholder { font-family: var(--font-sans); }

@media (pointer: coarse) {
  .ds-phone { block-size: 44px; }
  /* A floating list of 200 countries under an on-screen keyboard is
     unusable — go full-screen with a search field. */
  .ds-phone__list { position: fixed; inset: 0; }
}

Component API

PhoneInput

PropTypeDefaultDescription
value*string—E.164 only: "+447400123456". Never the formatted display string.
onChange*(e164: string) => void—Fires with the canonical value, whatever shape the input was in.
defaultCountrystring'US'ISO 3166-1 alpha-2. Resolve from the user profile first, then geo-IP, then the locale.
countriesstring[]—Restricts the list. Worth doing when you only ship to a few markets.
formatbooleantrueLive grouping as the user types. Never blocks a character it does not expect.
status'default' | 'error' | 'success' | 'warning''default'Success is useful here — a verified number is worth confirming.

Notes

Professional tips

  • Make the country list searchable past about twenty entries. Nobody scrolls to Zimbabwe, and typing "zim" is one second versus fifteen.
  • Sort your shipping markets to the top of the list with a divider beneath them, then the rest alphabetically.
  • Show the country’s own example number as the placeholder. It communicates the expected grouping without a word of instruction.
  • If you verify by SMS, show the number back in E.164 on the confirmation step. Users check the digits, and the grouped display hides a transposition.
  • Never require a phone number you do not dial. It is the single most abandonment-inducing optional field in most sign-up forms.

Performance

  • A full numbering-plan library is several hundred kilobytes. Load it lazily on focus, or ship the metadata for only the countries you serve.
  • Debounce validation until blur. Validating a partial number on every keystroke means showing an error for every number in the world while it is being typed.
  • Cache the mask per country rather than recomputing it on each render — masking runs on every keystroke.

Common mistakes

  • type="number", which strips the leading zero and makes the number undialable.
  • Storing the formatted string, so the telephony API is handed brackets and spaces.
  • A hand-written regex that rejects most of the world’s valid numbers.
  • maxLength on a mask, making some valid numbers impossible to enter.
  • A flag with no dial code, which is ambiguous at every size.
  • Rejecting pasted numbers with separators — which is how most numbers arrive.
  • A country list with no search, forcing a scroll through two hundred entries.

Real-world recommendations

  • Phone fields are among the highest-abandonment inputs in any sign-up flow. Every interaction you remove — a correct default country, a paste that works, a keypad that appears — shows up in the completion rate.
  • Users in countries with a trunk prefix habitually type the leading zero after the country code. Strip it silently rather than erroring; "+44 0 7400" is a person being careful, not a mistake.
  • For verification flows, keep the number editable on the code-entry screen. A mistyped number otherwise means restarting the whole flow.
  • If you only ship to two countries, a Select of those two beside a plain field beats a full international picker — but keep E.164 storage regardless.