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:

  1. On input, read the raw text and selectionStart.
  2. Count meaningful characters before the caret.
  3. Compute the new formatted text.
  4. 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.

Typing a digit into the middle of a card number Before the keystroke the text is 4111 1111 1111 with the caret after the sixth digit, at index seven. The user types 9, and the browser's edit gives 4111 19111 1111 with the caret at index eight, after seven digits. Reformatting to 4111 1911 1111 1 without caret handling puts the caret at the end, index seventeen. Mapping by meaningful characters places it after the seventh digit, at index eight, where the user expects it. Moment Text Caret before keystroke 4111 11|11 1111 after 6 digits browser inserts 9 4111 119|11 1111 after 7 digits reformat, no mapping 4111 1191 1111 1| end of field reformat, mapped 4111 119|1 1111 1 after 7 digits

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

  1. Define what “meaningful” means for the field. Digits for cards and phones; digits and the decimal separator for amounts; letters and digits for postcodes.
  2. Count meaningful characters before the caret, in the edited text. Read selectionStart inside the input handler — after the browser applied the edit, before you reformat.
  3. Format from the meaningful characters only. Throw away all existing separators and rebuild them; this handles paste and autofill with arbitrary formatting.
  4. Skip the write if nothing changed. Writing the same value still moves the caret in some browsers; compare first.
  5. Restore the selection by count. Map both selectionStart and selectionEnd, so a selected range stays selected after reformatting.
  6. 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.
  7. 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.

Backspace directly after an inserted space The caret is directly after the space in 4111 space 1111. The user presses Backspace. With a naive mask, the browser deletes the space, the mask reformats the same digits and re-inserts the space, and nothing appears to happen. With the beforeinput handler, the default deletion is prevented, the digit before the space is removed instead, the value is reformatted to 4111 111 and the caret is placed after the third digit of the first group's successor. User beforeinput handler Mask Backspace at "4111 |1111" naive: space deleted, mask re-inserts it preventDefault; delete digit before space "411|1 111" — caret after 3 digits

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.

Choose the least invasive formatting Formatting only on blur has no caret handling, no deletion problems and no IME concerns, and suits most fields. Light live grouping with caret mapping inserts separators while typing, needs the mapping and Backspace handling, and suits long digit strings such as card numbers. Full live masking with fixed literal characters and placeholders is the most complex and risky, and should be reserved for fixed formats where users benefit from seeing the structure. Format on blur No caret handling needed. Default for most fields. Live grouping + mapping Separators while typing. Card numbers, long codes. Full live mask Literal characters, placeholders. Only for fixed formats.

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.


Related

← Input Masking and Formatting