Input masks — the phone field that inserts brackets and dashes as you type, the card field that groups digits in fours, the amount field that adds thousands separators — promise fewer errors and cleaner data, and routinely deliver the opposite: the caret jumps to the end on every keystroke, pasted values are mangled, deleting a separator does nothing, screen readers announce punctuation the user never typed, and international numbers are rejected because the mask assumed one country’s format.
Formatting is a presentation concern layered on top of a value, and most masking bugs come from forgetting that layering — storing the formatted string, validating the display text, or rewriting the input’s value without accounting for where the caret was. This topic sets out a model with two representations, rules for when formatting should happen, and the guides that implement the hard parts. It sits within validation logic and schema integration because a formatted field’s validity is always a property of its raw value, and it builds on the raw-versus-parsed split described in controlled number inputs and intermediate values.
Problem statement
A formatted field has two values that users and code care about:
- The raw value — what the data means:
"07700900123","4111111111111111",123456.5,"2026-10-01". - The display value — how it is shown to humans:
"07700 900123","4111 1111 1111 1111","123,456.50","01/10/2026".
The field’s job is to accept whatever the user types or pastes, derive the raw value from it, and show a helpful display value — without ever losing a character the user meant, moving the caret somewhere unexpected, or blocking input that is valid but unfamiliar to the mask.
Masks go wrong in predictable ways:
- Caret jumps. Rewriting
input.valueputs the caret at the end. Every keystroke in the middle of a formatted value sends it there. - Destructive deletion. Backspacing over an inserted separator deletes nothing (the mask re-inserts it), trapping the user.
- Paste mangling. Pasting
+44 7700 900123into a mask expecting07700 900123truncates or scrambles it. - Over-constraint. A US-shaped phone mask rejects every non-US number; a date mask rejects the user’s locale order.
- Invisible structure. Screen readers read “open bracket zero seven seven” and users of voice control cannot dictate into a field that rejects spaces.
State machine specification
A formatted input moves between two modes, and the transitions are where most decisions live:
| State | Entered by | Displays | Leaves on |
|---|---|---|---|
editing |
focus, typing, paste | raw-ish text: user’s characters plus minimal live grouping | blur → formatted; submit |
formatted |
blur | full display format | focus → editing |
invalid |
blur or submit with a raw value that fails validation | the user’s text, unformatted, with an error | edit → editing |
Two principles follow. Format fully on blur, format lightly (or not at all) while typing — the user is mid-thought, and anything that changes characters around the caret risks moving it. And never format invalid input: if the raw value cannot be parsed, show exactly what the user typed so they can see and fix it.
Core implementation
The foundation is a formatter contract with three pure functions, plus a small controller that applies them to an input at the right moments and preserves the caret by counting meaningful characters rather than positions.
export interface Formatter<Raw> {
/** Characters that carry meaning (digits for phones/cards); others are formatting. */
isMeaningful(ch: string): boolean;
/** Parse whatever the user typed/pasted into a raw value, or null if impossible. */
parse(text: string): Raw | null;
/** Full display format for a valid raw value (used on blur). */
format(raw: Raw): string;
/** Optional light formatting while typing; must only INSERT separators. */
live?(text: string): string;
}
/** Keep the caret after the same number of meaningful characters. */
function caretAfterMeaningful(text: string, count: number, isMeaningful: (c: string) => boolean): number {
if (count <= 0) return 0;
let seen = 0;
for (let i = 0; i < text.length; i++) {
if (isMeaningful(text[i]) && ++seen === count) return i + 1;
}
return text.length;
}
export function attachFormatter<Raw>(
input: HTMLInputElement,
f: Formatter<Raw>,
onRaw: (raw: Raw | null, text: string) => void,
) {
const onInput = () => {
const text = input.value;
const caret = input.selectionStart ?? text.length;
if (f.live) {
// How many meaningful characters were before the caret BEFORE formatting?
const before = [...text.slice(0, caret)].filter(f.isMeaningful).length;
const next = f.live(text);
if (next !== text) {
input.value = next;
const pos = caretAfterMeaningful(next, before, f.isMeaningful);
input.setSelectionRange(pos, pos);
}
}
onRaw(f.parse(input.value), input.value);
};
const onBlur = () => {
const raw = f.parse(input.value);
if (raw !== null) input.value = f.format(raw); // only format what parsed
onRaw(raw, input.value);
};
input.addEventListener("input", onInput);
input.addEventListener("blur", onBlur);
return () => { input.removeEventListener("input", onInput); input.removeEventListener("blur", onBlur); };
}
// Example: card numbers — group digits in fours, live and on blur.
export const cardFormatter: Formatter<string> = {
isMeaningful: (c) => c >= "0" && c <= "9",
parse: (t) => { const d = t.replace(/\D/g, ""); return d.length >= 12 && d.length <= 19 ? d : null; },
format: (d) => d.replace(/(\d{4})(?=\d)/g, "$1 "),
live: (t) => t.replace(/\D/g, "").slice(0, 19).replace(/(\d{4})(?=\d)/g, "$1 "),
};
Validation — the schema, the Luhn check, the phone library — always runs on the output of parse, never on the display text. The form stores the raw value; the display value lives only in the input.
Integration guidance
Framework state. In React, Vue or Svelte, store the raw value and the current text separately, as in the number-input pattern. Controlled inputs must not write a re-formatted string back on every render while the field is focused, or the caret jumps; the caret-preservation logic is detailed in preserving caret position in masked inputs.
Schemas. The schema validates raw values: z.string().regex(/^\d{12,19}$/) plus a Luhn refinement for cards, a phone-library check for phones. Parsing from display text happens before the schema, in the same place as other coercion of form strings.
Locale. Currency, numbers and dates format differently by locale — decimal comma, digit grouping, day-month order. Use Intl for display and parse with the locale’s own separators, covered in locale-aware number and currency inputs.
Specific field types. Phone numbers are best handled by a real phone-number library rather than a hand-written mask, as in validating international phone numbers; card numbers combine grouping by brand with length and checksum validation, as in card number formatting and Luhn validation.
Autofill and paste. Browsers autofill and users paste fully formatted values from elsewhere (+44 (0) 7700 900-123). parse must accept any reasonable formatting, which is why it strips non-meaningful characters rather than expecting a fixed pattern.
Accessibility. Use inputmode (numeric, tel, decimal) for the right mobile keyboard, a visible hint showing the expected format, and never rely on placeholder text as the only instruction. Keep aria-describedby pointing to the hint and any error.
Deciding whether a field should be masked at all
Before choosing a masking technique, ask whether the field benefits from formatting at all. The strongest case is long strings of digits that people read back to check — card numbers, bank account numbers, long reference codes — where grouping into short chunks measurably reduces transcription errors. Currency amounts benefit from thousands separators on display, because 1000000 and 100000 are easy to confuse. Dates benefit from a clear, unambiguous display once entered, especially across locales that order day and month differently.
The weakest case is fields where the format varies by user, or where people already know how they like to type the value. Phone numbers are the classic example: formats differ by country, by mobile versus landline, and by personal habit, and a mask that enforces one layout is wrong for many users. Names, addresses and email addresses should never be masked. Postcodes sit in between — normalising case and spacing on blur is helpful, but blocking input that does not match one country’s pattern is not.
A useful rule: mask for reading, not for restricting. If the goal is to make a value easier to verify, format it — preferably on blur. If the goal is to prevent invalid input, validate the raw value and explain the problem; do not rely on the mask to make invalid input impossible, because it will also make some valid input impossible. Restrictive masks tend to fail silently for exactly the users least able to work around them — people using voice input, switch access or unfamiliar keyboard layouts — and those failures rarely reach analytics, because the user simply gives up.
Finally, consider the cost of the mask in the whole flow. A formatted field changes what assistive technology reads, what browser autofill must match, what the paste handler must accept and what automated tests must type. Each of those is manageable, but together they are a real maintenance cost, and it is worth paying only where the formatting clearly helps users read and check what they entered.
Edge cases and failure modes
Deleting a separator. When the user presses Backspace directly after a space the mask inserted, a naive mask re-inserts the space and the caret goes nowhere. Treat deletion of a formatting character as deletion of the meaningful character before it, or (simpler) do not format live and only format on blur.
IME composition. For languages that compose characters (Chinese, Japanese, Korean) and on some mobile keyboards, rewriting value during composition breaks input. Skip live formatting while event.isComposing is true and format on compositionend.
Right-to-left text. Formatted numbers inside RTL pages can display in unexpected order. Set dir="ltr" on inputs holding phone numbers, card numbers and other left-to-right codes.
Max length. A maxlength attribute counts display characters, including separators, so a 16-digit card with three spaces needs maxlength="19" — or better, no maxlength and a length rule on the raw value, so pasted text with extra formatting is not truncated.
Screen-reader verbosity. Some screen readers read separators aloud. Formatting only on blur keeps the editing experience clean; the formatted value is read once, on focus.
Voice input. Dictating “zero seven seven hundred…” produces words or unexpected digits. A lenient parse that accepts spaces and common formatting makes voice input workable; a mask that blocks non-digit characters makes it impossible.
Troubleshooting reference
| Symptom | Diagnostic step | Recovery |
|---|---|---|
| Caret jumps to the end while typing | Log selectionStart before and after the value write |
Restore the caret by meaningful-character count, or stop formatting live |
| Backspace over a space does nothing | Check whether live formatting re-inserts the separator | Delete the preceding meaningful character, or format only on blur |
| Pasted numbers are truncated | Check maxlength and the parse function |
Remove display maxlength; parse leniently, then validate raw length |
| International numbers rejected | Check the mask’s hard-coded pattern | Use a phone library with a country selector or E.164 input |
| Validation fails on correctly formatted input | Check whether the schema sees display text | Validate the output of parse, never input.value |
Testing and QA hooks
Tests for formatted fields should type character by character, not set values in one go — caret bugs only appear with incremental input. In Playwright, pressSequentially into the middle of an existing value (after moving the caret with arrow keys) and assert both the final text and selectionStart. Add paste tests with heavily formatted input, and a test that deletes a separator with Backspace.
Expose the raw value for tests with a data-raw-value attribute (or read form state directly in component tests) so assertions do not depend on display formatting. For accessibility regression coverage, assert that the hint is referenced by aria-describedby and that the input’s inputmode matches the field type.
Common pitfalls
- Storing the display value. Submissions contain spaces and brackets; comparisons and deduplication fail.
- Validating the display text. Every locale and formatting variant becomes a validation failure.
- Formatting on every keystroke without caret handling. The single most reported masking bug.
- Hard-coding one country’s format. Phone and postcode masks that only fit one country exclude everyone else.
- Blocking characters. Refusing spaces or dashes breaks paste, autofill and voice input; accept them and strip them in
parse.
Frequently Asked Questions
Should I use an input masking library?
Libraries handle caret preservation and deletion well, and are a good choice for fixed-pattern fields like card numbers. For phone numbers, prefer a dedicated phone library. Whatever you use, verify it with the test cases above — especially paste, autofill and mid-value editing.
Is formatting on blur only good enough?
Often it is the best choice: no caret problems, clean editing for screen-reader and voice users, and a tidy display once the user moves on. Light live grouping helps for long digit strings like card numbers, where reading 16 digits without spaces is error-prone.
What should the placeholder show?
Placeholders disappear as soon as the user types and are often low contrast. Put the expected format in a visible hint (“For example, 07700 900123”) linked with aria-describedby, and leave the placeholder empty or decorative.
How do masks interact with dirty tracking?
Compare raw values. Reformatting on blur changes the display text but not the raw value, so the field should not become dirty just because it was focused and blurred — the normalisation principle from deep equality for dirty detection on nested values.