Assigning input.value moves the caret to the end of the field, so any input that reformats as the user types — grouping card digits, inserting phone spaces, adding thousands separators — throws the caret to the end every time the user edits the middle of the value, and the next keystroke lands in the wrong place.
The fix is to record where the caret was in terms of meaning before reformatting, and put it back at the same meaningful position afterwards. This page, part of input masking and formatting, implements that mapping, then handles the cases that break simpler versions: deleting separators, selections, IME composition and frameworks that re-render the value.
Context and prerequisites
Positions in a formatted string are unstable: inserting a digit near the start of 4111 1111 1111 shifts every later space. What is stable is how many meaningful characters (digits, for a card number) come before the caret. If the caret was after the 5th digit before formatting, it should be after the 5th digit after formatting — wherever that is in the new string.
So the algorithm is:
- On
input, read the raw text andselectionStart. - Count meaningful characters before the caret.
- Compute the new formatted text.
- If it differs, assign it, then find the index just after the same count of meaningful characters, and set the selection there.
Two additional details make it robust: separators the user deletes must be treated as a request to delete the adjacent meaningful character, and nothing should be rewritten while an IME composition is in progress.
The core pattern: map the caret by meaningful-character count
export interface MaskSpec {
isMeaningful: (ch: string) => boolean; // e.g. digit test
format: (meaningful: string) => string; // formats the meaningful chars only
maxMeaningful?: number;
}
const meaningfulOf = (s: string, spec: MaskSpec) => [...s].filter(spec.isMeaningful).join("");
function indexAfterCount(text: string, count: number, spec: MaskSpec): number {
if (count <= 0) return 0;
let seen = 0;
for (let i = 0; i < text.length; i++) if (spec.isMeaningful(text[i]) && ++seen === count) return i + 1;
return text.length;
}
export function attachCaretSafeMask(input: HTMLInputElement, spec: MaskSpec) {
let composing = false;
let lastValue = input.value;
const apply = () => {
const text = input.value;
const selStart = input.selectionStart ?? text.length;
const selEnd = input.selectionEnd ?? selStart;
// Meaningful chars before each end of the selection, in the edited text.
const beforeStart = meaningfulOf(text.slice(0, selStart), spec).length;
const beforeEnd = meaningfulOf(text.slice(0, selEnd), spec).length;
let digits = meaningfulOf(text, spec);
if (spec.maxMeaningful) digits = digits.slice(0, spec.maxMeaningful);
const next = spec.format(digits);
lastValue = next;
if (next === text) return; // nothing to do: leave the caret alone
input.value = next;
// Restore both ends so a selection survives, then clamp.
input.setSelectionRange(indexAfterCount(next, beforeStart, spec), indexAfterCount(next, beforeEnd, spec));
};
const onBeforeInput = (e: InputEvent) => {
// Backspace directly after a separator: delete the meaningful char before it
// instead, otherwise the mask re-inserts the separator and nothing happens.
if (e.inputType !== "deleteContentBackward") return;
const pos = input.selectionStart ?? 0;
if (pos !== input.selectionEnd || pos === 0) return;
const prev = input.value[pos - 1];
if (!spec.isMeaningful(prev)) {
e.preventDefault();
const count = meaningfulOf(input.value.slice(0, pos), spec).length; // digits before caret
const digits = meaningfulOf(input.value, spec);
const nextDigits = digits.slice(0, count - 1) + digits.slice(count);
const next = spec.format(nextDigits);
input.value = next;
const at = indexAfterCount(next, count - 1, spec);
input.setSelectionRange(at, at);
input.dispatchEvent(new Event("input", { bubbles: true })); // keep listeners informed
}
};
const onInput = (e: Event) => { if (!composing && !(e as InputEvent).isComposing) apply(); };
const onCompStart = () => { composing = true; };
const onCompEnd = () => { composing = false; apply(); };
input.addEventListener("beforeinput", onBeforeInput);
input.addEventListener("input", onInput);
input.addEventListener("compositionstart", onCompStart);
input.addEventListener("compositionend", onCompEnd);
return () => {
input.removeEventListener("beforeinput", onBeforeInput);
input.removeEventListener("input", onInput);
input.removeEventListener("compositionstart", onCompStart);
input.removeEventListener("compositionend", onCompEnd);
};
}
// Card: groups of four digits, up to 19.
export const cardMask: MaskSpec = {
isMeaningful: (c) => c >= "0" && c <= "9",
format: (d) => d.replace(/(\d{4})(?=\d)/g, "$1 "),
maxMeaningful: 19,
};
Step-by-step walkthrough
- Define what “meaningful” means for the field. Digits for cards and phones; digits and the decimal separator for amounts; letters and digits for postcodes.
- Count meaningful characters before the caret, in the edited text. Read
selectionStartinside theinputhandler — after the browser applied the edit, before you reformat. - Format from the meaningful characters only. Throw away all existing separators and rebuild them; this handles paste and autofill with arbitrary formatting.
- Skip the write if nothing changed. Writing the same value still moves the caret in some browsers; compare first.
- Restore the selection by count. Map both
selectionStartandselectionEnd, so a selected range stays selected after reformatting. - Handle Backspace over separators in
beforeinput. Delete the preceding meaningful character instead, so users are never trapped behind a space the mask keeps re-inserting. - Leave composition alone. Skip formatting while an IME composition is active and format once on
compositionend.
Why controlled framework inputs need extra care
In React, a controlled <input value={formatted}> receives the formatted value on every render. If the component formats in render and the DOM value differs from what React last set, React writes it — and the caret goes to the end, undoing your careful mapping. Two approaches work. Keep the input uncontrolled for the display text and run the mask directly on the element (as above), storing only the raw value in state. Or keep it controlled, compute the next caret position in the change handler, store it in a ref, and apply it in a layout effect after React commits. The first approach is simpler and immune to re-render timing; the second fits libraries that insist on controlled inputs. The same distinction applies to v-model in Vue and bind:value in Svelte, discussed in controlled vs uncontrolled forms.
Failure modes and edge cases
1. Mobile keyboards and autocorrect
Some Android keyboards send insertCompositionText events even for Latin text, and some fire input without beforeinput. Test on real devices; the composition guard and the input-based formatting still produce correct results, while the Backspace enhancement may simply not trigger — which degrades gracefully.
2. Formatting that removes characters
If format drops characters (truncation at maxMeaningful), the caret count can exceed the available characters. indexAfterCount clamps to the end, which is correct.
3. type="number" inputs
selectionStart is null on type="number", so caret mapping is impossible. Use type="text" with inputmode="numeric", as in controlled number inputs and intermediate values.
4. Undo history
Programmatic value changes break the browser’s native undo stack in some browsers. If undo matters, prefer formatting on blur only, or use document.execCommand("insertText") — deprecated but still widely supported — which participates in undo history.
5. Screen readers announcing changes
Rewriting the value can cause some screen readers to re-read the field. Minimal live formatting (only inserting separators) reduces this; full formatting on blur avoids it during editing.
Verification checklist
Frequently Asked Questions
Why not just format on blur and avoid all of this?
For many fields, you should. Live formatting is worth its complexity mainly for long digit strings where grouping helps the user check what they are typing, such as card numbers and bank account numbers.
Does setSelectionRange work in all browsers?
Yes, on text-like inputs (text, tel, search, url, password). It throws or is unsupported on email and number inputs, which is another reason to use type="text" with inputmode for masked fields.
How do I test caret behaviour?
In Playwright, click into the field, use press("ArrowLeft") to position the caret, pressSequentially to type, then read selectionStart with evaluate. Unit tests with jsdom can simulate input events but do not reproduce every browser’s selection behaviour; keep at least a few real-browser tests.