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-top on the scroll container (usually html) 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-top on 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.

What the browser scrolls, and what users need to see With default focus scrolling, the browser scrolls just enough to show the input, which can end up under a 64 pixel sticky header with its label hidden. With scroll-padding-top set to the header height, the input stops below the header but the label above it may still be cut off. With the whole field wrapper scrolled into view and scroll-margin for breathing room, the label, input and error message are all visible below the header. Default focus scroll Input only. Can sit under the header. Label hidden. + scroll-padding-top Input clears the header. Label may still be cut. + field wrapper scroll Label, input, error all visible. Then focus without scrolling.

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

  1. Set scroll-padding-top on html to the sticky header’s height plus a gap. This single rule fixes focus scrolling, anchor links and scrollIntoView site-wide.
  2. Measure the header instead of hard-coding it. A ResizeObserver keeps the CSS variable correct when the header wraps on small screens or a banner is dismissed.
  3. Scroll the field wrapper, not the input. scrollIntoView({ block: "start" }) on the wrapper puts the label at the top of the visible area.
  4. Then focus with preventScroll: true. Otherwise the focus call scrolls again to the input and can hide the label.
  5. 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.
  6. 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.

A summary link click, step by step The user activates the summary link for the postcode error. The handler prevents the default fragment jump. It finds the postcode field wrapper and scrolls it into view aligned to the start, and scroll padding keeps it below the 64 pixel sticky header. It then focuses the postcode input with preventScroll so the view does not move again. The label, input and error message are all visible, and the fragment is written to the URL with replaceState. Click "Enter a real postcode" preventDefault() on the link. The default jump would align to the input. Scroll the .field wrapper block: start; smooth unless reduced motion. scroll-padding-top keeps it below the header. focus({ preventScroll: true }) No second scroll. Label, input and error visible together. replaceState(#postcode) Fragment kept for reloads. No extra history entry.

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.

Which property fixes which overlap A sticky or fixed header at the top is fixed with scroll-padding-top on the scroll container. A sticky footer or action bar is fixed with scroll-padding-bottom. A label hidden above an input that was scrolled to the top is fixed by scrolling the field wrapper and focusing with preventScroll. The mobile on-screen keyboard is handled with the visualViewport API to hide sticky footers. A form scrolling inside a panel needs scroll-padding on that panel rather than on html. Overlap Fix sticky/fixed header scroll-padding-top on the scroll container sticky footer / action bar scroll-padding-bottom label above input cut off scroll wrapper, then focus({ preventScroll }) mobile keyboard visualViewport: hide sticky footers form in a scrolling panel scroll-padding on the panel

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.


Related

← Focus Management After Validation