When a submit fails validation, keyboard and screen-reader users need focus moved to the first field that is wrong — in visual DOM order, not the order your validator happened to return — and moved without the double-scroll jitter that focus() causes when it fights your own scrollIntoView.
The bug this page fixes: submit fails, an error summary appears, but focus stays on the (now disabled or re-enabled) submit button at the bottom of the page. A sighted mouse user can scroll up to hunt for the red field; a keyboard user is stranded. Worse, when you do focus the field, the viewport lurches twice because the browser’s implicit focus-scroll and your explicit scroll disagree about alignment.
This is the foundational technique for focus management after validation; it composes with the aria-invalid timing work that flags those fields in the first place.
Precise ordering and the scroll problem
Two independent facts make this harder than “focus the first error”:
- Validation results are not in DOM order. A schema validator returns an object like
{ email: "...", firstName: "..." }keyed by declaration order, and a flat error array keyed by field registration order. Neither guarantees thatemailsits abovefirstNameon screen. You must resolve each invalid name to its element and sort bycompareDocumentPosition. focus()scrolls implicitly. The default behavior ofelement.focus()is to bring the element into view using the browser’s own alignment. If you then callscrollIntoViewto apply a sticky-header offset, the page moves twice.focus({ preventScroll: true })suppresses the implicit scroll so you own the single, correct scroll.
Core implementation
One focused function: given the set of invalid field names and the form element, resolve them to DOM nodes, sort by document position, reveal any collapsed ancestor, focus without scrolling, then scroll once.
interface FocusOptions {
/** Pixels of sticky header to offset the final scroll by. */
headerOffset?: number;
/** Map from field name to a callback that reveals its section. */
revealers?: Map<string, () => void>;
}
/**
* Focus the first invalid field in visual (DOM) order.
* Returns the focused element, or null if none could be resolved.
*/
async function focusFirstInvalid(
form: HTMLFormElement,
invalidNames: Iterable<string>,
{ headerOffset = 0, revealers }: FocusOptions = {}
): Promise<HTMLElement | null> {
// Resolve each name to a focusable element; drop names with no field.
const candidates: HTMLElement[] = [];
for (const name of invalidNames) {
const el = form.elements.namedItem(name);
// namedItem can return a RadioNodeList for grouped inputs; take item 0.
const node =
el instanceof RadioNodeList ? (el[0] as HTMLElement) : (el as HTMLElement | null);
if (node instanceof HTMLElement) candidates.push(node);
}
if (candidates.length === 0) return null;
// Sort by DOM order. compareDocumentPosition returns a bitmask;
// FOLLOWING means `b` comes after `a`, so `a` should sort first.
candidates.sort((a, b) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
);
const first = candidates[0];
// Reveal a collapsed section BEFORE focusing — you cannot focus a node
// that is display:none or inside a closed <details>.
const reveal = revealers?.get(fieldName(first));
if (reveal) {
reveal();
// Wait one frame so the newly-shown element has layout box + is focusable.
await new Promise((r) => requestAnimationFrame(() => r(null)));
}
// preventScroll stops the browser's implicit focus-scroll so it does not
// fight the manual scrollIntoView below (the double-jump bug).
first.focus({ preventScroll: true });
// A single deliberate scroll with a header offset. scrollMarginTop lets
// scrollIntoView respect the sticky header without manual math.
first.style.scrollMarginTop = `${headerOffset}px`;
first.scrollIntoView({ block: "start", behavior: "smooth" });
return first;
}
/** Recover a field's name whether it is a plain input or grouped control. */
function fieldName(el: HTMLElement): string {
return (el as HTMLInputElement).name || el.getAttribute("name") || "";
}
Wiring it to a submit handler, together with the flagging pass from the aria-invalid controller:
form.addEventListener("submit", async (e) => {
e.preventDefault();
const result = validate(readValues(form)); // your validator
if (result.valid) return submit();
const invalidNames = Object.keys(result.errors);
await focusFirstInvalid(form, invalidNames, {
headerOffset: 72, // height of the sticky app header
revealers: sectionRevealers, // Map<name, () => openAccordion(section)>
});
});
Step-by-step walkthrough
- Resolve names to nodes.
form.elements.namedItem(name)maps a field name to its element, handlingRadioNodeListfor radio and checkbox groups by taking the first member. - Sort by document position.
compareDocumentPositionwith theDOCUMENT_POSITION_FOLLOWINGbit produces a comparator that orders candidates top-to-bottom as they appear on screen, independent of validator output order. - Reveal before focus. If the first candidate lives in a collapsed section, its
revealercallback opens it, and a singlerequestAnimationFrameawait lets layout settle so the element is actually focusable. - Focus without scrolling.
focus({ preventScroll: true })moves the accessibility focus and caret without the browser scrolling — this is the line that kills the double jump. - Scroll once, with offset. Setting
scrollMarginTopto the sticky-header height and callingscrollIntoView({ block: "start" })produces exactly one smooth scroll that clears the header.
Failure modes and edge cases
The double-scroll jump
Omitting preventScroll is the single most common cause of the viewport lurching twice. The browser scrolls on focus(), then your scrollIntoView scrolls again.
// WRONG — browser scrolls, then you scroll again
first.focus();
first.scrollIntoView();
// RIGHT — suppress the implicit scroll, own the single one
first.focus({ preventScroll: true });
first.scrollIntoView({ block: "start" });
Focusing a hidden field silently fails
display:none and closed <details> elements are not focusable — focus() is a no-op and focus stays where it was. Always run the revealer and await a frame before focusing. If a field is hidden by design (a conditional branch that is not applicable), exclude it from invalidNames upstream rather than trying to focus it.
RadioNodeList resolves to a list, not an element
For radio and checkbox groups, namedItem returns a RadioNodeList. Calling .focus() on the list throws. Take list[0], or better, the currently checked member if there is one. Grouped inputs also benefit from roving tabindex so the right member receives focus.
Smooth scroll never settles under reduced-motion
behavior: "smooth" is ignored — correctly — when the user has prefers-reduced-motion: reduce. Do not depend on a smooth-scroll completion callback; there is no reliable one across browsers anyway. If you must act after the scroll, key off scrollend where supported and fall back to a timeout.
The field scrolls under a sticky header
Without scrollMarginTop, scrollIntoView({ block: "start" }) aligns the field to the very top of the viewport, tucked behind a fixed header. Set scrollMarginTop to the header height so the field lands just below it.
Verification checklist
Frequently Asked Questions
Why does the page jump twice when I focus the first invalid field?
Calling focus() scrolls the element into view with the browser’s default alignment, then your own scrollIntoView scrolls it again to a different position. Pass { preventScroll: true } to focus() so the browser does not scroll, then run a single scrollIntoView with the offset you actually want. Setting scrollMarginTop to your header height makes that one scroll clear a sticky header cleanly.
How do I focus a field inside a collapsed accordion section?
You cannot focus an element that is display:none or inside a closed <details> element. Expand the containing section first, wait one animation frame for layout, then focus. Track a map from field name to the section that must be opened, and open it before calling focus. The implementation runs the section’s revealer and awaits requestAnimationFrame before the focus call for exactly this reason.
Should I sort invalid fields by DOM order or by validation order?
Always DOM order. A validation library returns errors in object-key or schema order, which rarely matches visual top-to-bottom layout. Focusing the first error in DOM order matches user expectation and reading direction. Sort candidates by compareDocumentPosition before choosing one — the DOCUMENT_POSITION_FOLLOWING bit gives you a clean top-to-bottom comparator.