Phone input
A country select welded to a number field. Choosing a country sets the dial code, the flag and the formatting mask; typing formats the digits against that mask as they arrive. The number itself always reads left to right, whatever direction the page runs in — because a phone number does.
When to use it
Where you need a number you will actually dial or text: a delivery contact, two-factor
setup, an account recovery number. The country cell is what makes it worth having — it
is the difference between storing 07700 900123 and storing
+447700900123.
Where the number is a label rather than a destination — a reference someone reads back
over the phone — a plain .input is less machinery
for the same result.
The markup
Three parts, and the data lives on the <option> elements rather than
anywhere in Deck: data-code is the dial code, data-flag is the
character shown, and data-mask is the format, where # is a
digit and everything else is punctuation to insert.
<div class="field" style="max-inline-size:24rem">
<label class="label" for="dx-ph-num">Mobile number</label>
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇬🇧</span>
<span class="phone-code">+44</span>
<select aria-label="Country">
<option data-code="+44" data-flag="🇬🇧" data-mask="##### ######">UK</option>
<option data-code="+1" data-flag="🇺🇸" data-mask="(###) ###-####">US</option>
<option data-code="+33" data-flag="🇫🇷" data-mask="# ## ## ## ##">FR</option>
<option data-code="+81" data-flag="🇯🇵" data-mask="##-####-####">JP</option>
</select>
</span>
<input id="dx-ph-num" type="tel" inputmode="tel" autocomplete="tel-national">
</div>
</div>
<div class="field" style="max-inline-size:24rem">
<label class="label" for="dx-ph-num">Mobile number</label>
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇬🇧</span>
<span class="phone-code">+44</span>
<select aria-label="Country">
<option data-code="+44" data-flag="🇬🇧" data-mask="##### ######">UK</option>
<option data-code="+1" data-flag="🇺🇸" data-mask="(###) ###-####">US</option>
<option data-code="+33" data-flag="🇫🇷" data-mask="# ## ## ## ##">FR</option>
<option data-code="+81" data-flag="🇯🇵" data-mask="##-####-####">JP</option>
</select>
</span>
<input id="dx-ph-num" type="tel" inputmode="tel" autocomplete="tel-national">
</div>
</div>
The <select> is stripped to nothing —
appearance: none, no border, no background — and sits on top of the flag
and code, which are the visible part. It is a real select, so it gets the platform's own
picker on a phone and full keyboard support everywhere.
Formatting and validity
-
Everything that is not a digit is discarded, then re-added from the
mask. Paste
+44 7700 900123and you get the digits laid back out in the selected country's shape. -
The placeholder comes from the mask, with every
#shown as0— so the field shows the shape of the number it wants before anyone types. -
.is-validis added when the digit count matches the mask. Not a real validation: it counts#characters. A number with the right number of wrong digits is "valid". -
A
deck:changeevent fires on blur, bubbling, withdetail.code,detail.number(digits only) anddetail.e164— the dial code and digits concatenated, which is what you store. - No country data ships with Deck. The four options above are the example, not a library. Masks, names and ordering are yours, which is deliberate — a complete country list is a data problem with political edges and it does not belong in a stylesheet.
Complete numbers get a green border. The tick is yours to show and hide.
<div class="stack stack-2" style="max-inline-size:24rem">
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇺🇸</span>
<span class="phone-code">+1</span>
<select aria-label="Country">
<option data-code="+1" data-flag="🇺🇸" data-mask="(###) ###-####">US</option>
<option data-code="+44" data-flag="🇬🇧" data-mask="##### ######">UK</option>
</select>
</span>
<input type="tel" inputmode="tel" value="2025550147" aria-label="Phone number">
<span class="phone-status">
<svg class="icon text-good" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
</span>
</div>
<p class="help">Complete numbers get a green border. The tick is yours to show and hide.</p>
</div>
<div class="stack stack-2" style="max-inline-size:24rem">
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇺🇸</span>
<span class="phone-code">+1</span>
<select aria-label="Country">
<option data-code="+1" data-flag="🇺🇸" data-mask="(###) ###-####">US</option>
<option data-code="+44" data-flag="🇬🇧" data-mask="##### ######">UK</option>
</select>
</span>
<input type="tel" inputmode="tel" value="2025550147" aria-label="Phone number">
<span class="phone-status">
<svg class="icon text-good" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
</span>
</div>
<p class="help">Complete numbers get a green border. The tick is yours to show and hide.</p>
</div>
.phone-status is an empty slot at the end of the row, centred and sized to
hold an icon. Deck never puts anything in it; showing a tick when
.is-valid appears is your code's job.
Classes
Generated from src/23-inputs.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.phone |
deck.components |
Country select welded to the number. The number stays LTR even in an RTL document, because a phone number always reads left to right. | 23-inputs.css:148 |
.phone-code |
deck.components |
Sets font-variant-numeric, color, font-weight. | 23-inputs.css:184 |
.phone-country |
deck.components |
No declarations. | 23-inputs.css:159 |
.phone-flag |
deck.components |
Sets font-size, line-height. | 23-inputs.css:183 |
.phone-status |
deck.components |
Sets display, place-items, padding-inline, flex. | 23-inputs.css:202 |
.is-valid |
deck.components |
No declarations. | 23-inputs.css:201 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
|
--ring |
0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42)) |
|
--good-500 |
oklch(62% var(--chroma-status) var(--hue-good)) |
|
--r-sm |
7px |
|
--text-sm |
.8125rem |
|
--text-base |
.9375rem |
Accessibility
-
The country select needs its own label. It is visually a flag and a
dial code, and neither is its accessible name.
aria-label="Country"on the select, as in every example here. -
The flag is decoration and should be hidden. A flag emoji is
announced as a country name on some platforms, as "flag" on others and as nothing on
the rest.
aria-hidden="true", and let the option text carry the country. -
Editing the middle of a number loses a digit. The script rebuilds the
whole value on every keystroke and assigns it back, which collapses the caret to the
end. Worse, the rebuild is positional: put the caret mid-number in
(202) 555-0147, type9, and you get(202) 559-5014— the digit is inserted, everything after it shifts along, and the last one falls off the end of the mask with no warning. Measured, not theorised. Correcting a typo mid-number is effectively impossible; readers have to clear the field and start again. It is the sharpest edge on this component and is recorded inFINDINGS.md. -
.is-validis a colour and nothing else. A green border is invisible to a screen reader and to a good number of sighted readers. If completeness matters, say it in text near the field. -
Use the real attributes.
type="tel"gets the phone keypad and the right autofill category;autocomplete="tel"orautocomplete="tel-national"lets the browser fill it. Deck styles the field and cannot supply these. - Do not reject on the mask. The digit count is a hint, not a validation — numbers vary in length within a country, and people travel. Server-side validation is the real check, and a number the mask dislikes should still be submittable.
Right to left
This is the one Deck component that deliberately refuses to mirror its contents. The row
flips — the country cell moves to the right — but the number field carries
direction: ltr and unicode-bidi: isolate, so the digits and
their punctuation stay in dialling order and cannot be re-ordered by the surrounding
text.
That is correct rather than an oversight. +44 is not a quantity to be read
in the reader's direction; it is a sequence you dial from the left. The
isolate is what stops a bracket in (###) jumping to the wrong
end of the string.
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="dx-ph-rtl">رقم الهاتف</label>
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇦🇪</span>
<span class="phone-code">+971</span>
<select aria-label="الدولة">
<option data-code="+971" data-flag="🇦🇪" data-mask="## ### ####">AE</option>
<option data-code="+20" data-flag="🇪🇬" data-mask="### ### ####">EG</option>
</select>
</span>
<input id="dx-ph-rtl" type="tel" inputmode="tel" value="501234567">
</div>
</div>
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="dx-ph-rtl">رقم الهاتف</label>
<div class="phone">
<span class="phone-country">
<span class="phone-flag" aria-hidden="true">🇦🇪</span>
<span class="phone-code">+971</span>
<select aria-label="الدولة">
<option data-code="+971" data-flag="🇦🇪" data-mask="## ### ####">AE</option>
<option data-code="+20" data-flag="🇪🇬" data-mask="### ### ####">EG</option>
</select>
</span>
<input id="dx-ph-rtl" type="tel" inputmode="tel" value="501234567">
</div>
</div>
Reduced motion
Nothing on this component animates. The only transitions in the row belong to the
country cell's hover background, which is instant enough not to be motion, and the focus
ring, which is not transitioned at all. There is nothing for
prefers-reduced-motion to switch off.
Printing
.phone is not in src/99-print.css, and it prints acceptably:
the flag, the dial code and the formatted number are all real text in the flow. The
borders and the tinted country cell print as furniture, and the green
.is-valid border is a background that most browsers drop.
The flag is the one risk — a colour emoji printed in greyscale can come out as a dark block. The dial code beside it is what carries the information, which is why it is there as text rather than implied by the flag.
Overriding it
@layer app.components {
/* Room for a longer country code list */
.phone > .phone-country select { max-inline-size: 7rem; }
/* Show the tick only once the script marks the row complete */
.phone-status { visibility: hidden; }
.phone.is-valid .phone-status { visibility: visible; }
/* Drop the flags entirely — the dial code is the useful half */
.phone-flag { display: none; }
}
When not to use it
-
Not for a single-country form. If every number is British, the
country cell is a select with one option. Use an
.inputwithtype="tel"and put "+44" in the label. - Not as validation. The mask counts digits. It does not know a real number from a made-up one of the right length, and it will reject valid numbers whose length it did not anticipate.
- Not for a number people edit rather than enter. A stored number someone comes back to change one digit of is the worst case for this component — see Accessibility. Editing mid-number drops a digit.
-
Not without script. With
deck-extras.jsblocked the country select changes nothing, no mask is applied and the flag and code stay on whatever was rendered. The field still posts, so the value is not lost — but it is unformatted and the visible dial code may be wrong. -
Not for any other kind of number. A card number, a postcode or a
reference has a different shape and different privacy rules. Use
.input, or.numberfor genuine quantities.