Tyler Coderre

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.

Two long time favorite inputs in my rotation, my Planck keyboard and Logitech MX Master 3s
Two long time favorite inputs in my rotation, my Planck keyboard and Logitech MX Master 3s

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:

  1. Minimum viable HTML: a single-line input that is semantic and works without JavaScript.
  2. 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:

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

HTML
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel">

What it does: phone-friendly keyboard and better autofill.

Enhanced HTML

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

Javascript
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

HTML
<input id="date" name="date" type="text" inputmode="numeric" autocomplete="bday">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="cc" name="cc" type="text" inputmode="numeric" autocomplete="cc-number">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="amount" name="amount" type="text" inputmode="decimal" autocomplete="off">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="ssn" name="ssn" type="text" inputmode="numeric" autocomplete="off">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="zip" name="zip" type="text" inputmode="numeric" autocomplete="postal-code">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="time" name="time" type="text" inputmode="numeric" autocomplete="off">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="otp" name="otp" type="text" inputmode="numeric" autocomplete="one-time-code">

Enhanced HTML for JS

HTML
<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

Javascript
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)

HTML
<input
  id="otp"
  name="one-time-code"
  type="text"
  inputmode="numeric"
  autocomplete="one-time-code"
  autocapitalize="off"
  spellcheck="false"
/>

Why this helps on Apple

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:

Enhanced HTML for JS

HTML
<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.

HTML
.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

Javascript
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

HTML
<input id="pw" name="pw" type="password" autocomplete="current-password">

Enhanced HTML for JS

HTML
<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

Javascript
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

HTML
<input id="iban" name="iban" type="text" inputmode="text" autocomplete="off">

Enhanced HTML for JS

HTML
<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

Javascript
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:

  1. Display format
  2. Stored format
  3. Allowed characters
  4. When validation runs (input, blur, submit)
  5. Error message behavior and placement
  6. 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.

Please enter your name.

Please enter a valid email address.

Case Studies.

Featured6

Case Study Archive18

Some studies are linked; others are available on request.