The Input Mask Field Guide
A designer-led reference for form inputs that feel obvious to users, predictable to developers, and easier for product teams to reason about.
Forms are where good UX goes to die. Not because forms are inherently evil, but because forms are where assumptions collide.
Users assume “I just type the thing.”
Design assumes “the format is obvious.”
Engineering assumes “we’ll validate it later.”
Product assumes “it’s a small story.”
Then someone on mobile tries to enter a phone number, realizes their keypad does not even have parentheses, and suddenly your “small story” is a conversion cliff.
Input masks are one of the simplest ways to prevent that. They also happen to be a great forcing function for cross-team clarity. Done well, they reduce errors and make inputs feel effortless. Done poorly, they turn typing into a boss fight.
This guide is meant to live in that sweet spot between reference and tool kit. I’m writing it from a design-systems mindset, but it’s built to be useful for developers and product folks too. Every pattern comes in two layers:
- Minimum viable HTML: a single-line input that is semantic and works without JavaScript.
- Enhanced version: optional JS that improves formatting, reduces errors, and adds context. Progressive enhancement only.
If the JS never loads, you still have a functional form. You just lose the “nice.”
A Couple Rules Before We Get Into The List
If you only remember a few things, remember these:
- Masks should guide, not control.
- Paste should always work. People paste everything.
- Labels should be real. Placeholders are not instruction.
- Decide and document display value vs stored value. This is the part that saves everyone time.
Alright. Let’s do the list.
1. Phone Number Formatting
This one is the classic. It’s also the one that instantly reveals whether anyone tested the form on a phone.
Minimum Viable
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel">
What it does: phone-friendly keyboard and better autofill.
Enhanced HTML
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" aria-describedby="phone-help">
<p id="phone-help">Format: (555) 123-4567</p>
Optional JS
const phoneEl = document.querySelector("#phone");
function formatUSPhone(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 10);
const a = digits.slice(0, 3);
const b = digits.slice(3, 6);
const c = digits.slice(6);
if (digits.length <= 3) return a;
if (digits.length <= 6) return `(${a}) ${b}`;
return `(${a}) ${b}-${c}`;
}
phoneEl?.addEventListener("input", () => {
phoneEl.value = formatUSPhone(phoneEl.value);
});
Why include JS: inserts punctuation so users do not have to switch keyboards.
Design systems note: define whether the stored value is digits-only or formatted.
If you have ever rage-typed a phone number into a field that refuses spaces and then yells at you for not adding dashes, congratulations. You have met the bad version of this pattern.
2. Date (MM/DD/YYYY)
Dates are deceptively hard. Format is region-dependent, validation is messy, and everyone has opinions.
Minimum Viable
<input id="date" name="date" type="text" inputmode="numeric" autocomplete="bday">
Enhanced HTML for JS
<label for="date">Date</label>
<input id="date" name="date" type="text" inputmode="numeric" autocomplete="bday" aria-describedby="date-help">
<p id="date-help">Format: MM/DD/YYYY</p>
Optional JS
const dateEl = document.querySelector("#date");
function formatDateMMDDYYYY(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 8);
const mm = digits.slice(0, 2);
const dd = digits.slice(2, 4);
const yyyy = digits.slice(4);
if (digits.length <= 2) return mm;
if (digits.length <= 4) return `${mm}/${dd}`;
return `${mm}/${dd}/${yyyy}`;
}
dateEl?.addEventListener("input", () => {
dateEl.value = formatDateMMDDYYYY(dateEl.value);
});
Why include JS: auto-slashes reduce format confusion mid-entry.
Product note: if you ship internationally, make this locale-aware or use an accessible date picker.
Nothing makes a team discover “we never decided our date format” faster than a production bug report from someone in another country.
3. Credit Card Number
Grouped Spacing
If users cannot scan the digits, they will misread them. Grouping is not fluff, it’s error prevention.
Minimum Viable
<input id="cc" name="cc" type="text" inputmode="numeric" autocomplete="cc-number">
Enhanced HTML for JS
<label for="cc">Card number</label>
<input id="cc" name="cc" type="text" inputmode="numeric" autocomplete="cc-number" aria-describedby="cc-help">
<p id="cc-help">We’ll add spacing for readability.</p>
Optional JS
const ccEl = document.querySelector("#cc");
function formatCardNumber(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 19);
return digits.replace(/(.{4})/g, "$1 ").trim();
}
ccEl?.addEventListener("input", () => {
ccEl.value = formatCardNumber(ccEl.value);
});
Why include JS: makes long numbers readable and easier to verify.
If you want to watch a designer get intensely focused, ask them to read a 16-digit number out loud with no spacing. It turns into a live stress test.
4. Currency Amount
Normalize While Typing, Format on Blur
This is where teams accidentally create an input that fights the user. The trick is to be gentle.
Minimum Viable
<input id="amount" name="amount" type="text" inputmode="decimal" autocomplete="off">
Enhanced HTML for JS
<label for="amount">Amount</label>
<input id="amount" name="amount" type="text" inputmode="decimal" autocomplete="off" aria-describedby="amount-help">
<p id="amount-help">Example: 1250.50</p>
Optional JS
const amountEl = document.querySelector("#amount");
function normalizeCurrency(raw) {
const cleaned = raw.replace(/[^\d.]/g, "");
const [whole = "", frac = ""] = cleaned.split(".");
const w = (whole || "0").replace(/^0+(?=\d)/, "");
const f = frac.slice(0, 2);
return f ? `${w}.${f}` : w;
}
function formatCurrencyDisplay(raw) {
const n = Number(normalizeCurrency(raw));
if (!Number.isFinite(n)) return "";
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
amountEl?.addEventListener("input", () => {
amountEl.value = normalizeCurrency(amountEl.value);
});
amountEl?.addEventListener("blur", () => {
amountEl.value = formatCurrencyDisplay(amountEl.value);
});
Why include JS: avoids the “I cannot type the number I want” problem. Normalize first. Pretty later.
If your currency input inserts commas while I’m in the middle of typing, I will immediately assume the rest of your app is also going to argue with me.
5. SSN-Style Separators (###-##-####)
Same concept as dates. Different punctuation. Still valuable for readability.
Minimum Viable
<input id="ssn" name="ssn" type="text" inputmode="numeric" autocomplete="off">
Enhanced HTML for JS
<label for="ssn">SSN</label>
<input id="ssn" name="ssn" type="text" inputmode="numeric" autocomplete="off" aria-describedby="ssn-help">
<p id="ssn-help">Format: 123-45-6789</p>
Optional JS
const ssnEl = document.querySelector("#ssn");
function formatSSN(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 9);
const a = digits.slice(0, 3);
const b = digits.slice(3, 5);
const c = digits.slice(5);
if (digits.length <= 3) return a;
if (digits.length <= 5) return `${a}-${b}`;
return `${a}-${b}-${c}`;
}
ssnEl?.addEventListener("input", () => {
ssnEl.value = formatSSN(ssnEl.value);
});
Product check: do you actually need full SSN, or just last 4.
If product says “we need SSN,” ask “why” three times. The answer is often “we actually just need last 4” and you just saved everyone a security review.
6. ZIP / Postal Code (##### or #####-####)
This is a great example of a mask that stays out of the way.
Minimum Viable
<input id="zip" name="zip" type="text" inputmode="numeric" autocomplete="postal-code">
Enhanced HTML for JS
<label for="zip">ZIP code</label>
<input id="zip" name="zip" type="text" inputmode="numeric" autocomplete="postal-code" aria-describedby="zip-help">
<p id="zip-help">5 digits, or ZIP+4.</p>
Optional JS
const zipEl = document.querySelector("#zip");
function formatZIP(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 9);
if (digits.length <= 5) return digits;
return `${digits.slice(0, 5)}-${digits.slice(5)}`;
}
zipEl?.addEventListener("input", () => {
zipEl.value = formatZIP(zipEl.value);
});
ZIP+4 is the “optional side quest” of form inputs. Great when available, never required for basic gameplay.
7. Time (HH:MM)
This one is simple, but it removes hesitation.
Minimum Viable
<input id="time" name="time" type="text" inputmode="numeric" autocomplete="off">
Enhanced HTML for JS
<label for="time">Time</label>
<input id="time" name="time" type="text" inputmode="numeric" aria-describedby="time-help">
<p id="time-help">Format: HH:MM</p>
Optional JS
const timeEl = document.querySelector("#time");
function formatTime(raw) {
const digits = raw.replace(/\D/g, "").slice(0, 4);
const hh = digits.slice(0, 2);
const mm = digits.slice(2);
if (digits.length <= 2) return hh;
return `${hh}:${mm}`;
}
timeEl?.addEventListener("input", () => {
timeEl.value = formatTime(timeEl.value);
});
A surprising amount of form UX is just removing the moment where someone pauses and thinks “wait, what do they mean.”
8. One-Time Password (OTP) with Paste Support
This one feels like magic when it’s done right, and like punishment when it’s done wrong. Paste support is the whole game.
Minimum viable
<input id="otp" name="otp" type="text" inputmode="numeric" autocomplete="one-time-code">
Enhanced HTML for JS
<fieldset>
<legend>Verification code</legend>
<div>
<input class="otp" inputmode="numeric" aria-label="Digit 1" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 2" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 3" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 4" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 5" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 6" maxlength="1">
</div>
</fieldset>
Optional JS
const otpEls = Array.from(document.querySelectorAll(".otp"));
otpEls.forEach((el, idx) => {
el.addEventListener("input", () => {
el.value = el.value.replace(/\D/g, "").slice(0, 1);
if (el.value && otpEls[idx + 1]) otpEls[idx + 1].focus();
});
el.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !el.value && otpEls[idx - 1]) otpEls[idx - 1].focus();
});
el.addEventListener("paste", (e) => {
const text = (e.clipboardData?.getData("text") ?? "").replace(/\D/g, "");
if (!text) return;
e.preventDefault();
text.slice(0, otpEls.length).split("").forEach((d, i) => {
if (otpEls[i]) otpEls[i].value = d;
});
otpEls[Math.min(text.length, otpEls.length) - 1]?.focus();
});
});
Why include JS: auto-advance, backspace navigation, paste. That last one is non-negotiable.
OTP fields without paste support feel like they were designed by someone who has never received an OTP code in their life.
Remove Even More Friction with Auto-Fill
One thing I do enjoy that Apple does, is allows you to auto-fill these codes when hooked up properly. To do this is pretty simple:
Minimum Viable (updated)
<input
id="otp"
name="one-time-code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
autocapitalize="off"
spellcheck="false"
/>
Why this helps on Apple
- autocomplete=”one-time-code” is the key that triggers iOS’s OTP suggestion bar from SMS and Mail (when the OS detects a code).
- A single input is the most reliable target for the automatic “From Messages” / “From Mail” code suggestion.
If You Keep the 6-field OTP UI, Add a Hidden “Collector” Field (Recommended)
Multi-box OTP can work, but iOS autofill is more reliable when there is a single input to fill. The clean pattern is:
- One hidden or visually-hidden single input that iOS can autofill into.
- When it receives a full code, your JS splits it into the 6 boxes.
Enhanced HTML for JS
<label for="otp-full" class="sr-only">Verification code</label>
<input
id="otp-full"
name="one-time-code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
autocapitalize="off"
spellcheck="false"
class="sr-only"
/>
<fieldset aria-describedby="otp-help">
<legend>Verification code</legend>
<p id="otp-help">6 digits</p>
<div>
<input class="otp" inputmode="numeric" aria-label="Digit 1" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 2" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 3" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 4" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 5" maxlength="1">
<input class="otp" inputmode="numeric" aria-label="Digit 6" maxlength="1">
</div>
</fieldset>
Minimal “sr-only” Utility
If you do not already have one.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Updated Optional JS
This adds Autofill support
const otpEls = Array.from(document.querySelectorAll(".otp"));
const otpFull = document.querySelector("#otp-full");
function fillOtpBoxes(code) {
const digits = (code ?? "").replace(/\D/g, "").slice(0, otpEls.length);
digits.split("").forEach((d, i) => {
if (otpEls[i]) otpEls[i].value = d;
});
// Focus the last filled box for clarity
const last = Math.min(digits.length, otpEls.length) - 1;
if (last >= 0) otpEls[last]?.focus();
}
otpEls.forEach((el, idx) => {
el.addEventListener("input", () => {
el.value = el.value.replace(/\D/g, "").slice(0, 1);
if (el.value && otpEls[idx + 1]) otpEls[idx + 1].focus();
});
el.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !el.value && otpEls[idx - 1]) otpEls[idx - 1].focus();
});
el.addEventListener("paste", (e) => {
const text = (e.clipboardData?.getData("text") ?? "");
if (!text) return;
e.preventDefault();
fillOtpBoxes(text);
});
});
// iOS autofill usually sets the value without triggering a normal "paste" event.
// Listen for input/change on the single field and fan it out.
otpFull?.addEventListener("input", () => {
fillOtpBoxes(otpFull.value);
});
otpFull?.addEventListener("change", () => {
fillOtpBoxes(otpFull.value);
});
// Optional: when user taps into the first box, focus the single field briefly
// to encourage iOS to show the OTP suggestion, then bounce focus back.
otpEls[0]?.addEventListener("focus", () => {
if (!otpFull) return;
otpFull.focus();
// Return focus back quickly so the multi-box UI still feels natural.
requestAnimationFrame(() => otpEls[0]?.focus());
});
Why this works: iOS now sees autocomplete=”one-time-code” on a real input and offers the code suggestion. So your UI still gets the multi-box “looks nice” experience. And importantly, the code is still accessible because there’s a single labelled input in the DOM.
9. Password Field Enhancements
Show, Hide, and Caps Lock Warning
This is not a mask in the traditional sense, but it belongs in the same toolbox. This is error prevention.
Minimum Viable
<input id="pw" name="pw" type="password" autocomplete="current-password">
Enhanced HTML for JS
<label for="pw">Password</label>
<input id="pw" name="pw" type="password" autocomplete="current-password" aria-describedby="pw-help">
<p id="pw-help">Use at least 12 characters.</p>
<button type="button" id="pw-toggle" aria-controls="pw" aria-pressed="false">Show</button>
<p id="caps" hidden>Caps Lock is on.</p>
Optional JS
const pwEl = document.querySelector("#pw");
const toggleEl = document.querySelector("#pw-toggle");
const capsEl = document.querySelector("#caps");
toggleEl?.addEventListener("click", () => {
const isHidden = pwEl.type === "password";
pwEl.type = isHidden ? "text" : "password";
toggleEl.setAttribute("aria-pressed", String(isHidden));
toggleEl.textContent = isHidden ? "Hide" : "Show";
});
pwEl?.addEventListener("keyup", (e) => {
const capsOn = e.getModifierState && e.getModifierState("CapsLock");
if (capsEl) capsEl.hidden = !capsOn;
});
Password UX is where you discover whether your product thinks users are adversaries or customers.
10. IBAN
IBAN is short for International Bank Account Number. It’s a standardized format used in many countries for cross-border and domestic bank transfers. It’s long, it’s alphanumeric, and it is extremely easy to misread when it’s presented as a single unbroken string. Grouping is basically a kindness.
Minimum Viable
<input id="iban" name="iban" type="text" inputmode="text" autocomplete="off">
Enhanced HTML for JS
<label for="iban">International bank account number (IBAN)</label>
<input id="iban" name="iban" type="text" inputmode="text" autocomplete="off" aria-describedby="iban-help">
<p id="iban-help">We’ll add spacing for readability. Example: GB33BUKB20201555555555</p>
Optional JS
const ibanEl = document.querySelector("#iban");
function formatIBAN(raw) {
const cleaned = raw.replace(/[^a-z0-9]/gi, "").toUpperCase().slice(0, 34);
return cleaned.replace(/(.{4})/g, "$1 ").trim();
}
ibanEl?.addEventListener("input", () => {
ibanEl.value = formatIBAN(ibanEl.value);
});
Why include JS: makes long strings scannable and reduces transcription mistakes.
This is the moment where someone on the team says “why would we ever need this,” and someone else says “because we have customers outside the US,” and then you all learn something.
The Part That Makes This Useful Across Teams
When you add any masked input to a design system, write down these six things and you eliminate most of the churn:
- Display format
- Stored format
- Allowed characters
- When validation runs (input, blur, submit)
- Error message behavior and placement
- Paste/autofill/mobile keyboard expectations
That is the handoff. Not the pixels.
If you want, tell me which of these your product uses today and which ones you want standardized. I can turn this into a compact component-spec template and a QA checklist you can reuse across teams.
Still Curious?
Browse One of My Other Case Studies, Writings, Resources, Or Reach out for a Chat.
You can contact and connect with me through email, on Dribbble, or LinkedIn as well.