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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
Display vs. storage
What the user reads and what the database holds are two different strings, and only one of them is dialable.
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.
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.
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
Stored as +447400123456
Stored as +447400123456
Stored as +447400123456
Stored as +447400123456
Stored as +447400123456
Stored as +447400123456
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.
- 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.
- 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.
- 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.
- PlaceholderA real example number
The country’s own example, not "Enter phone number". It teaches the expected grouping without a single word of instruction.
- 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.
- Shared focus ringAround the whole field
One value, one field, one ring. Two separate rings makes it look like two questions.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| country padding | Country button |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | — | The number, so groups stay aligned |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Country hover and focus transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Max width | When to use |
|---|---|---|---|---|---|---|
| Small | 32px | 0 10px | 13px | 13rem | — | Dense CRM forms and table filters. |
| Medium | 36px | 0 10px | 15px | 15rem | — | The default. |
| Large | 44px | 0 12px | 16px | 16rem | — | 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 list | max 256px | — | — | — | 18rem | Searchable past about twenty entries — nobody scrolls to Zimbabwe. |
display: 7400 123456
stored: +447400123456"+44 (0) 7400-123-456" → +447400123456type="tel" inputmode="tel"
autocomplete="tel-national"/^\d{10}$/ → rejects most of the worldNot a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Reaches 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–Z | Typeahead in the country list, matching the country name — not the dial code, which nobody remembers. |
| Esc | Closes the country list and returns focus to the button. |
| ⌘ / Ctrl + V | Pastes 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.
| Attribute | Applied to | Notes |
|---|---|---|
| autocomplete="tel-national" | The number field | With autocomplete="tel-country-code" on the country control. Split tokens are what let a browser fill both halves correctly. |
| aria-label | The country button | Must include the country and the code: "Country code: United Kingdom +44". A flag has no accessible name. |
| role="listbox" / "option" | The country list | With aria-selected on the current country. |
| aria-describedby | The number field | Points at the example format, so the expected shape is read before typing rather than discovered by failing. |
| aria-invalid | The number field | On a validation failure, paired with a message naming the country: "That number is too short for the United Kingdom." |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| defaultCountry | string | 'US' | ISO 3166-1 alpha-2. Resolve from the user profile first, then geo-IP, then the locale. |
| countries | string[] | — | Restricts the list. Worth doing when you only ship to a few markets. |
| format | boolean | true | Live 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. |
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.