Moving focus to the first invalid field is correct, and on many sites it lands the field behind the sticky header: the input is technically in the viewport, so the browser does not scroll further, but its label and error message are covered — sighted keyboard users see a focus ring with no context, and WCAG 2.2’s “Focus Not Obscured” criterion fails.
The fix is mostly declarative CSS — scroll-padding-top on the scroll container and scroll-margin on fields — plus a little script to scroll the whole field (label, input, error) into view rather than just the input. This page, part of focus management after validation, applies both and covers the mobile keyboard, which obscures from the bottom.
Context and prerequisites
What the browser does when you call input.focus(): if the element is not fully in view, it scrolls the minimum amount to show the element — not its label above it, not its error below it, and with no knowledge of fixed or sticky overlays. With a 64px sticky header, an input scrolled to the top edge sits under the header.
Two CSS properties change the scroll target:
scroll-padding-topon the scroll container (usuallyhtml) shrinks the “visible area” used for scroll calculations — so every scroll-into-view, anchor jump and focus scroll leaves room for the header.scroll-margin-topon an element adds extra space above it when it is scrolled into view — useful to include the label above an input.
WCAG 2.2 Success Criterion 2.4.11 (Focus Not Obscured, Minimum, AA) requires that a focused element is not entirely hidden by author-created content; 2.4.12 (Enhanced, AAA) requires that no part is hidden. Showing the label and error as well goes beyond both and is what users actually need.
The core pattern: CSS scroll padding plus a field-aware focus helper
:root {
--header-h: 64px; /* keep in sync with the sticky header's real height */
--footer-h: 0px; /* a sticky action bar at the bottom, if any */
}
html {
/* Every scroll-into-view, anchor link and focus scroll respects the header. */
scroll-padding-top: calc(var(--header-h) + 16px);
scroll-padding-bottom: calc(var(--footer-h) + 16px);
}
.field {
/* Breathing room so the label is not flush against the header. */
scroll-margin-top: 8px;
}
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
/**
* Bring the whole field (label + input + error) into view, then focus the
* input without a second scroll. Works for error-summary links and for
* "focus first invalid field" after submit.
*/
export function focusField(input: HTMLElement) {
const field = input.closest<HTMLElement>(".field, fieldset") ?? input;
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// 'start' aligns the wrapper's top (the label) with the padded viewport top.
field.scrollIntoView({ block: "start", behavior: reduce ? "auto" : "smooth" });
// preventScroll: we already scrolled the WRAPPER; letting focus scroll again
// would re-align to the input and undo the label's visibility.
input.focus({ preventScroll: true });
}
// Error summary links: intercept to use the field-aware scroll.
export function wireSummaryLinks(summary: HTMLElement) {
summary.addEventListener("click", (e) => {
const a = (e.target as HTMLElement).closest("a[href^='#']");
if (!a) return;
const target = document.getElementById(a.getAttribute("href")!.slice(1));
if (!target) return;
e.preventDefault();
focusField(target);
history.replaceState(null, "", a.getAttribute("href")!); // keep the fragment for reloads
});
}
// Keep --header-h accurate when the header's height changes (wrapping nav, banners).
export function syncHeaderHeight(header: HTMLElement) {
const ro = new ResizeObserver(([entry]) =>
document.documentElement.style.setProperty("--header-h", `${Math.ceil(entry.borderBoxSize[0].blockSize)}px`));
ro.observe(header);
return () => ro.disconnect();
}
Step-by-step walkthrough
- Set
scroll-padding-toponhtmlto the sticky header’s height plus a gap. This single rule fixes focus scrolling, anchor links andscrollIntoViewsite-wide. - Measure the header instead of hard-coding it. A
ResizeObserverkeeps the CSS variable correct when the header wraps on small screens or a banner is dismissed. - Scroll the field wrapper, not the input.
scrollIntoView({ block: "start" })on the wrapper puts the label at the top of the visible area. - Then focus with
preventScroll: true. Otherwise the focus call scrolls again to the input and can hide the label. - Use the helper for summary links and first-invalid focus. Both paths — see moving focus to the first invalid field and building an accessible error summary — should land identically.
- Respect reduced motion. Smooth scrolling only when the user has not asked for reduced motion.
Why this is also a focus-visibility issue for everyday typing
The same problem appears without any errors: a keyboard user tabbing down a long form moves focus into fields near the bottom of the viewport, and a sticky footer — a “Save” bar, a cookie banner — covers them. scroll-padding-bottom handles the tabbing case the same way scroll-padding-top handles the header, because browsers use scroll padding when scrolling focused elements into view. Fixing the error path with CSS therefore fixes ordinary keyboard navigation too, which is exactly what WCAG’s Focus Not Obscured criteria are about.
Failure modes and edge cases
1. Scroll containers other than the page
If the form scrolls inside a panel (overflow: auto), put scroll-padding on that container, not on html, and make sure its own sticky elements are accounted for.
2. position: fixed headers
Fixed headers overlay content just like sticky ones and need the same padding. The difference is only that fixed headers also need body padding to avoid covering content at the top of the page.
3. The mobile on-screen keyboard
When a focused input opens the keyboard, the visual viewport shrinks from the bottom. Browsers usually scroll the input above the keyboard, but sticky footers can still overlap. Use the visualViewport API (resize event) to hide or move sticky footers while the keyboard is open, or avoid sticky footers in long forms.
4. Smooth scroll and focus timing
With smooth scrolling, the field arrives in view over a few hundred milliseconds. Focusing immediately with preventScroll is fine — focus does not wait for the animation — but screen magnifier users may prefer instant scrolling; honour prefers-reduced-motion.
5. Collapsed sections
If the invalid field sits inside a closed <details> or accordion, open it before scrolling, or scrollIntoView targets a hidden element and nothing visible happens.
Verification checklist
Frequently Asked Questions
Does scroll-padding work with element.focus()?
Yes — browsers use the scroll container’s scroll padding when scrolling a focused element into view. It also applies to fragment navigation and scrollIntoView. Test in all target browsers, since older versions handled focus scrolling differently.
Why not just scroll to the top of the form on submit?
The error summary may be at the top, and scrolling there is right when you focus the summary. When you focus a specific field, the field’s context must be visible; the top of the form is not where the problem is.
Is Focus Not Obscured only about sticky headers?
No — any author-created content that can cover the focused element counts: cookie banners, chat widgets, toasts. Scroll padding handles fixed edges; for floating widgets, make them dismissible or move them away from focused content.