Browser autofill writes values straight into the DOM, and when it does so without firing the events your controlled inputs listen to, the screen shows a filled form while your state still holds empty strings — so validation blocks a submit the user can see is complete.
The mechanics of who owns a field’s value are laid out in controlled vs uncontrolled forms. Autofill is the case that breaks the controlled model’s core promise: that state is the single source of truth and the DOM merely reflects it. Here the DOM changes first, and the question is how to pull that change back into state before anything reads it.
Context and prerequisites
Modern Chromium, Firefox and Safari do fire an input event when they autofill a text field in most cases, which is why autofill “mostly works” in React and Vue. The failures cluster around three situations:
- Chrome’s pre-fill on page load. Chrome can paint saved credentials into fields before the user interacts with the page, but it withholds the actual value from script (
el.valuereads as"") until the user clicks or presses a key anywhere. This is a privacy measure, not a bug, and no event fires when the value becomes readable. - Safari and iOS keychain fills of several fields at once. The focused field gets its event; the sibling fields filled in the same gesture sometimes do not, particularly inside shadow DOM or when the fields were rendered after the page loaded.
- Password managers implemented as extensions. They set
valuethrough the property setter, which bypasses React’s value tracker, and may or may not dispatch a syntheticinputevent afterwards.
So a robust form does not rely on any single signal. It listens for events, detects the autofill paint state through CSS, and — as a last line of defence — reads the DOM at submit time.
The core pattern: three layers of reconciliation
type Values = Record<string, string>;
/**
* Keeps a controlled form's state in step with values the browser or a
* password manager wrote into the DOM behind its back.
*/
export function attachAutofillSync(
form: HTMLFormElement,
getState: () => Values,
setField: (name: string, value: string) => void,
): () => void {
// Layer 1: CSS-driven detection. Chromium and WebKit apply :autofill
// (and the legacy :-webkit-autofill). We pair it with a keyframe animation
// in CSS so that "became autofilled" produces an animationstart event.
const onAnimationStart = (e: AnimationEvent) => {
if (e.animationName !== "csf-autofill-start") return;
const el = e.target as HTMLInputElement;
if (el.name && el.value !== getState()[el.name]) setField(el.name, el.value);
};
// Layer 2: a catch-all 'change' listener on the form. Some fills skip
// 'input' but still fire 'change' on blur; delegation covers fields that
// mount later (e.g. a second wizard step).
const onChange = (e: Event) => {
const el = e.target as HTMLInputElement;
if (el.name && el.value !== getState()[el.name]) setField(el.name, el.value);
};
// Layer 3: reconcile everything just before validation on submit. This is
// the only layer that catches Chrome's "readable after first interaction"
// case, because the submit click IS the first interaction.
// Capture phase so it runs before the framework's own submit handler.
const onSubmitCapture = () => {
const state = getState();
for (const el of Array.from(form.elements) as HTMLInputElement[]) {
if (!el.name || el.type === "file") continue;
if (el.value !== state[el.name]) setField(el.name, el.value);
}
};
form.addEventListener("animationstart", onAnimationStart, true);
form.addEventListener("change", onChange);
form.addEventListener("submit", onSubmitCapture, true);
return () => {
form.removeEventListener("animationstart", onAnimationStart, true);
form.removeEventListener("change", onChange);
form.removeEventListener("submit", onSubmitCapture, true);
};
}
/* The animation does nothing visible; it exists so the browser tells us
(via animationstart) the moment a field enters the autofilled state. */
@keyframes csf-autofill-start { from { outline-color: inherit; } to { outline-color: inherit; } }
input:autofill,
input:-webkit-autofill { animation: csf-autofill-start 1ms; }
In React the submit-time layer needs one extra step: setField enqueues a state update, so your submit handler must validate the values it just read from the DOM rather than the state snapshot captured by its closure. Build the payload from new FormData(form) merged over state, and validate that.
Step-by-step walkthrough
- Give every field a real
nameand the rightautocompletetoken. Autofill keys offautocomplete="email","current-password","postal-code"and friends; the reconciliation code keys offname. Both must be present, and the tokens are catalogued in autocomplete tokens for autofill-friendly forms. - Add the
:autofillanimation hook in CSS. It converts a paint-state change into an event you can listen for, which is the only way to learn about fills that dispatch nothing. - Listen for
changeon the form, not per field. Delegation catches fields rendered after the listener was attached and those inside a later wizard step. - Reconcile in the capture phase of
submit. Walkform.elements, compare each value with state, and write the differences before validation runs. - Validate what you just reconciled. In frameworks with asynchronous state updates, build the payload from the DOM-merged values rather than a stale closure.
- Clear stale “required” errors after reconciliation. If an error was shown for an empty field that autofill has since filled, re-run that field’s validator so the message disappears, following the rules in revalidating after the first submit.
Failure modes and edge cases
1. Reading el.value on load and getting an empty string
You cannot defeat Chrome’s withholding, and you should not try (polling el.value or calling focus() on page load does not unlock it). Design so that nothing important happens before the first interaction: do not run validation on mount, and do not disable the submit button based on empty state, because the button would stay disabled over a visibly filled form.
2. Floating labels that overlap the autofilled value
A label positioned by a value ? "raised" : "resting" class stays resting while state is empty, so it sits on top of the filled text. Drive the raised state from CSS instead:
.field input:is(:focus, :not(:placeholder-shown), :autofill) + label { transform: translateY(-1.2rem) scale(0.85); }
3. React’s value tracker swallows a setter-based fill
When an extension sets input.value = "x" and then dispatches new Event("input", { bubbles: true }), React compares against its tracked value and may decide nothing changed. The submit-time layer covers this; do not reach for private React internals to “fix” it.
4. Autofill writes into fields you hid
Address autofill happily fills a hidden “company” or “address line 2” field. When you reconcile at submit, respect the rules in skipping validation for disabled and hidden fields so a hidden field’s autofilled value is neither validated nor sent unless you intend it.
5. One-time codes and autofill on iOS
SMS code autofill (autocomplete="one-time-code") inserts the whole code into one field. If you split the code across several inputs, the fill lands in the first box only. Use a single input, or distribute the pasted value as described in one-time code inputs.
Verification checklist
Frequently Asked Questions
Why does el.value return an empty string for a field I can see is filled?
Chrome withholds prefilled credential values from script until the user interacts with the page, to stop a hostile page harvesting saved passwords without consent. The value becomes readable after the first click or key press, and no event fires when that happens, so reconcile at submit time.
Is the :autofill animation trick still needed in current browsers?
It is less critical than it was, because most text fills now dispatch input. It still earns its place for multi-field fills and for UI that must react before submit — raising labels, clearing a “required” hint — and it costs one CSS rule and one listener.
Should I turn autofill off with autocomplete="off" to avoid all this?
No. Browsers ignore autocomplete="off" for credential fields, and disabling autofill on address or payment fields makes forms slower and harder to use, especially for people with motor or cognitive disabilities. Handle autofill; do not fight it.