Field Notes

The sticky header that eats your focus

Shift+Tab back up a form and the field you land on slides under the sticky header. The browser scrolled just far enough, and just far enough is behind the bar. WCAG 2.2 calls it focus not obscured. One CSS property fixes it.

Published

  • keyboard
  • focus
  • layout
  • wcag 2.2

A keyboard user is filling in a form. They press Shift+Tab to go back to a field they skipped. Focus moves to it. The page scrolls so the field is in view. And the field is nowhere, because the page has a sticky header and the field is behind it. They are focused on something they cannot see.

Why it happens

When focus moves to an element outside the visible area, the browser scrolls so that the element is inside it. Scrolling the minimum distance is a reasonable default, and it lands the element at the very edge of the viewport or the scrolling container. If the element was above, it lands at the top edge. A sticky or fixed header sits on top of that edge. So the element lands exactly where the header is.

The same thing happens at the bottom with a sticky footer, a cookie bar, or a chat widget, and happens to any focused element: a link in text, a button, a tab. It only shows up when focus arrives by keyboard, because a pointer user has already scrolled the target into the open before they click it. That is why it is easy to ship.

The criterion

Success criterion 2.4.11, Focus Not Obscured (Minimum), is new in version 2.2 of the guidelines. It says that when an element receives focus, it is not entirely hidden by content the author made. "Entirely" is the AA line. The AAA version, 2.4.12, says not hidden at all. A field half under a header passes the minimum and fails the enhanced level. A field completely under it fails both.

The criterion is about content the author put there. A browser's own toolbar does not count. A sticky header does.

The fix is one line

Tell the browser where the real top of the scrolling area is:

html {
  scroll-padding-top: 64px; /* the header's height, plus a little */
}

scroll-padding sets an inset on the scrollport. Every scroll the browser makes to bring something into view, whether for focus, for a fragment link, or for scrollIntoView(), respects it. With scroll-padding-top set to the header's height, "just far enough" now means "just far enough to clear the header". Put it on html for a page that scrolls, or on the scrolling container when the header is inside a panel. If the header changes height on narrow screens, change the padding with it.

The property has been in every major browser engine for years. It costs nothing, and it fixes fragment links at the same time, which have the same problem for the same reason.

What does not fix it:

  • scroll-margin on each field. It works, but it has to be on every element that can receive focus, including the ones added later.
  • A script that scrolls after focus. It fights the browser and flickers.
  • Making the header not sticky on focus. Visitors notice.

How to test

Scroll to the bottom of a page with a sticky header. Press Shift+Tab until focus moves to something above the visible area. Look at where it landed. If you have to scroll to find it, the page fails.

An agent can do this with a scripted browser. Focus each stop in turn and read its box. Read the box of every element with position: sticky or position: fixed. Report any stop whose box is inside one of them. That is rule AW-FOCUS-004 in the ruleset, added with this note, and corpus page 34 plants the case.

Try it

The Tab Walk lab has a scrolling frame with a 56 px sticky header and eight fields. Press the button under it: the frame scrolls to the bottom and focus moves back to field 3, and the readout says what fraction of the field is under the header. Tick the scroll-padding-top fix and press again.

Sources

  • w3.org, Understanding Success Criterion 2.4.11: Focus Not Obscured (Minimum)
  • w3.org, Understanding Success Criterion 2.4.12: Focus Not Obscured (Enhanced)
  • developer.mozilla.org, scroll-padding

Open the companion lab

  • Tab order is a promise the DOM makes

    The Tab key follows the HTML, not the layout. CSS cannot change that order, a positive tabindex hijacks it, and aria-hidden does not remove a stop. One real walk found 162 stops where a list of the markup said 64. Here is why, and how to check.

  • Where focus goes when a dialog opens

    A dialog owes a keyboard user three things. Put focus inside it on open. Keep Tab inside while it is open. Give focus back to the opener on close. Miss any one and the reader is lost behind the dialog or dropped at the top of the page.

  • Twenty-four pixels is a smaller promise than it sounds

    WCAG 2.2 asks for pointer targets of 24 by 24 CSS pixels. The spacing exception means a row of 18 px icons can pass and a row of 24 px icons can be hard to hit. Here is the rule, the circle, and the five exceptions, drawn.

Spotted something this note gets wrong?

Corrections and better examples are the best kind of message.

Send a correction