The Caret Jumps to the End
Format an input as the user types and the caret teleports right. Anchor it to a digit count, not an offset.
You format the amount field as the user types — thousands separators, a card mask, an upper-cased postcode. It looks great, right up until someone corrects a digit in the middle and the caret teleports to the end of the field. They fix it. It happens again. They stop trusting the form.
Both fields run the same formatter on every keystroke. The left one writes the result straight back; the right one also restores the caret. Seed the fields, click between two digits, and type — the left caret jumps to the far right, the right caret stays exactly where you were working. Watch the caret readouts under each.
Why the browser gives up
A text input keeps its selection as a pair of character offsets. When you set value to a string the element has never seen being edited, those offsets are meaningless — so the browser does the only safe thing and collapses the selection to the end. It is not a React problem; the same thing happens with plain input.value = …. React just makes it easy to do on every keystroke.
The fix that doesn’t work
The obvious move is to save selectionStart before formatting and write it back after. It works until the formatter changes the length: type a digit that pushes 1,234 to 12,345 and a separator appears to the left of your caret, so the old offset now points one character too early. Every time a group boundary is crossed, the caret slips.
The edges that bite
Two cases are worth calling out. First, a count of zero must map to index 0, not to “after the first separator” — otherwise typing at the very start of a masked field pushes the caret past the leading character. Second, deletion: backspacing a separator deletes a character the user did not type, and because the separator is re-derived from the digits, the field appears unchanged. Counting digits handles this honestly — the digit count didn’t change, so the caret doesn’t move, and the next backspace removes a real digit.
Where to put it
The caret has to be restored after React commits the new value, but before the browser paints, or it flickers. That is exactly what useLayoutEffect is for: stash the target in a ref during onChange, apply it with setSelectionRange in the layout effect. The demo above does precisely this.
The implementation
formatWithCaret takes any pure string transform, so the same twelve lines cover currency, card masks, phone numbers and postcodes. The predicate for “significant” is a parameter — pass a different one for alphanumeric masks.
/**
* Reformat an input's value without throwing the caret to the end.
*
* The bug: you format as the user types — grouping digits, inserting dashes,
* upper-casing — and write the result back to a controlled input. The browser
* has no way to know where the caret "should" be in a string it did not see
* being edited, so it parks it at the end. Every edit in the middle of the
* field yanks the user to the far right.
*
* The fix is not to remember the caret's numeric index — separators shift it.
* It is to remember *how many significant characters* were to the left of it,
* then find the position with that same count in the formatted output. The
* caret follows the content it was anchored to, not a byte offset.
*
* Pure and DOM-free: the demo on this page and the unit tests use it directly.
*/
export interface CaretResult {
value: string;
caret: number;
}
/** Decides which characters "count" when anchoring the caret. */
export type Significant = (char: string) => boolean;
const isDigit: Significant = (char) => char >= "0" && char <= "9";
/** Count significant characters in `text[0..end)`. */
function countSignificant(text: string, end: number, significant: Significant): number {
let count = 0;
for (let i = 0; i < Math.min(end, text.length); i++) {
if (significant(text[i])) count += 1;
}
return count;
}
/**
* Index in `text` just after the `target`-th significant character. Returns
* `text.length` when there are fewer than `target` of them.
*
* Note the `target === 0` case: the caret belongs *before* any leading
* separators, otherwise typing at the very start of "(12) 34" would drop the
* caret after the "(".
*/
function indexAfterSignificant(text: string, target: number, significant: Significant): number {
if (target === 0) return 0;
let count = 0;
for (let i = 0; i < text.length; i++) {
if (significant(text[i])) {
count += 1;
if (count === target) return i + 1;
}
}
return text.length;
}
/**
* Apply `format` to `raw` and map `caret` into the formatted string.
*
* @param raw the input's current value, as the user left it
* @param caret selectionStart at that moment
* @param format any pure string transform (grouping, masking, casing)
* @param significant which characters anchor the caret; digits by default
*/
export function formatWithCaret(
raw: string,
caret: number,
format: (value: string) => string,
significant: Significant = isDigit,
): CaretResult {
const before = countSignificant(raw, caret, significant);
const value = format(raw);
return { value, caret: indexAfterSignificant(value, before, significant) };
}
export interface GroupOptions {
/** Characters per group, counting from the right. */
size?: number;
separator?: string;
}
/**
* Group digits from the right — 1234567 → 1,234,567. Non-digits in the input
* are dropped, which is what makes this safe to run on every keystroke.
*/
export function groupDigits(raw: string, { size = 3, separator = "," }: GroupOptions = {}): string {
const digits = raw.replace(/\D/g, "");
if (digits.length === 0) return "";
const groups: string[] = [];
for (let end = digits.length; end > 0; end -= size) {
groups.unshift(digits.slice(Math.max(end - size, 0), end));
}
return groups.join(separator);
}
/** A card-number style mask: 4242424242424242 → 4242 4242 4242 4242. */
export function groupFromLeft(raw: string, { size = 4, separator = " " }: GroupOptions = {}): string {
const digits = raw.replace(/\D/g, "");
const groups: string[] = [];
for (let start = 0; start < digits.length; start += size) {
groups.push(digits.slice(start, start + size));
}
return groups.join(separator);
}
/**
* What the naive implementation does: format, and let the caret fall to the
* end. Kept here so the demo's "before" column is real code rather than a
* description of code.
*/
export function formatNaively(raw: string, format: (value: string) => string): CaretResult {
const value = format(raw);
return { value, caret: value.length };
}