Skip to content
inklu
wcagwcag-2-2focus-managementcsscomplianceengineering

Focus Not Obscured: the WCAG 2.2 criterion your sticky header keeps breaking

A practical guide to WCAG 2.2 success criterion 2.4.11, Focus Not Obscured (Minimum) — what it requires, the four UI patterns that break it, and the CSS that fixes them.

By Moe· Co-founder, Design8 min read

Put your cursor in the address bar of your own product, press Tab, and keep pressing. Somewhere around the fourth or fifth stop on a long page, the focused element will scroll into view underneath your sticky header and disappear. The browser scrolled it to the top of the viewport, which the browser thinks is visible, and your 72-pixel navigation bar is sitting on top of it. The focus ring is still there. You just cannot see it.

That is a failure of WCAG 2.2 success criterion 2.4.11, Focus Not Obscured (Minimum), and it is Level AA — in scope for AODA, ADA Title II, and the EAA, the same as every other AA criterion. It is also close to invisible in a normal QA pass, because nothing is broken in the way a developer expects broken to look. The tab order is correct, the focus indicator is rendered, the element is technically in the viewport. It is just behind something.

What 2.4.11 actually requires

The rule: when a user interface component receives keyboard focus, the component must not be entirely hidden by author-created content.

Two words are doing all the work there.

Entirely. The Minimum-level criterion only fails when the focused component is completely covered. If one edge of a button pokes out from under your header, 2.4.11 passes. That is a low bar, and it is deliberate — the stricter version is 2.4.12, Focus Not Obscured (Enhanced), which is Level AAA and requires that no part of the focused component is hidden. Most teams are conforming to AA, so 2.4.11 is your working number, but a sliver of a button visible under a navbar is a legal pass and a usability failure. Design for 2.4.12 and you get 2.4.11 for free.

Author-created. Content the browser puts on screen does not count. If a native autocomplete dropdown or a browser-rendered dialog covers the focused element, that is not your failure. The moment the covering thing is your sticky header, your cookie banner, your chat launcher, or your custom dropdown, you own it.

One more distinction worth nailing down, because it causes a lot of confused audit conversations: 2.4.11 is not 2.4.7, Focus Visible. Focus Visible asks whether a focus indicator exists at all. Focus Not Obscured asks whether you can see it once it does. A page can have a beautifully styled 3-pixel focus ring on every control and still fail 2.4.11 on every one of them.

The four patterns that break it

Failures cluster. In practice almost everything I see falls into one of four buckets.

Sticky headers and footers. The dominant cause. position: sticky or position: fixed on a top navigation bar means the browser's default scroll-into-view behavior — which aligns the focused element to the very top edge of the scrollport — parks it directly underneath. Sticky footers do the same thing at the bottom of the page, and sticky sidebars do it horizontally on wide tables and dashboards.

Cookie and consent banners. These are the worst offenders because they are usually third-party, usually fixed to the bottom, usually tall on mobile, and usually rendered before a user has had a chance to Tab anywhere. A consent bar that covers the bottom 140 pixels of a viewport will obscure focus on everything a keyboard user reaches until they dismiss it — and if the dismiss button is itself hard to reach, the trap is closed.

Chat widgets and floating action buttons. A support launcher pinned to the bottom-right corner covers a small but real region of every page. On a form with a right-aligned submit button, that is exactly where the last and most important control in the flow lives.

Non-modal overlays that do not move focus. Toast notifications, sticky promo bars, "you have unsaved changes" strips. A real modal dialog usually traps focus, so it is not the problem. Content that appears on top without taking focus is the problem, because the user keeps tabbing underneath it.

The fix is usually one CSS property

For the sticky-header case — which is most of them — the fix is scroll-padding-top on the scrolling container, almost always the root element. It tells the browser to treat the top N pixels of the scrollport as reserved, so anything scrolled into view stops below your header instead of under it.

:root {
  --header-height: 72px;
  /* Reserve the sticky header's space for all scroll-into-view behavior */
  scroll-padding-top: var(--header-height);
}

That single declaration covers keyboard focus, anchor-link jumps, and scrollIntoView() calls, because they all respect scroll padding. If you also have a sticky footer or consent bar, reserve that too:

:root {
  scroll-padding-top: var(--header-height);
  scroll-padding-bottom: var(--consent-bar-height, 0px);
}

The trap: header height is rarely a constant. It changes at breakpoints, it changes when the header condenses on scroll, and it changes when a promo bar is present. Hard-coding 72 pixels and then shipping an 88-pixel mobile header puts you right back where you started. Drive it from the same token the header itself uses, and when the height is genuinely dynamic, measure it.

// Keep scroll-padding in sync with a header whose height can change
useEffect(() => {
  const header = headerRef.current
  if (!header) return
  const sync = () => {
    document.documentElement.style.scrollPaddingTop = `${header.offsetHeight}px`
  }
  sync()
  const observer = new ResizeObserver(sync)
  observer.observe(header)
  return () => observer.disconnect()
}, [])

For a single element that needs more clearance than the global value — a form field inside a section with its own sticky subheader, say — scroll-margin-top on that element adds to the reservation locally.

For chat widgets and floating buttons, scroll padding does not help, because the obstruction is not at the edge of the scroll direction. The options there are to move the widget out of the way when focus enters the region behind it, or to reserve layout space with padding on the page container so no interactive content ever sits underneath it. Reserving space is less clever and more reliable.

Consent banners deserve their own decision: if the banner is blocking, make it an actual modal dialog that traps focus and must be dismissed, so nothing behind it is reachable while it is up. If it is non-blocking, reserve its height with scroll-padding-bottom and page padding. The failure mode is the middle ground — a non-modal bar that covers content users can still tab to.

How to actually test for it

This is a criterion where automation only gets you part of the way, and it is worth being straight about which part.

A scanner can find the conditions for the failure reliably: elements with position: fixed or position: sticky that occupy an edge of the viewport, combined with a root element that has no corresponding scroll-padding. That combination is a near-certain 2.4.11 failure on any page long enough to scroll, and it is a structural property of your CSS, which means it is detectable in source before the page is ever rendered.

What a scanner cannot do on its own is judge the edge cases — whether a partially covered element is covered entirely, whether the covering element is author content or user-agent content, whether a given overlay takes focus when it appears. Those need a person, and the person's test takes about ninety seconds:

  1. Load the page at a mobile-ish viewport height, around 700 pixels, where sticky chrome eats the largest share of the screen.
  2. Click the address bar and press Tab repeatedly through the entire page. Do not use the mouse.
  3. Watch for any stop where the focus ring vanishes or is clipped.
  4. Repeat with the consent banner present, not dismissed.
  5. Repeat with the chat widget open.

Step 4 is the one everyone skips, because developers clear their cookie banner on the first load of the day and never see it again.

Why this one is worth fixing early

Focus Not Obscured is cheap to fix and expensive to retrofit. One line of scroll padding at the root, set from the design token that already defines your header height, resolves the majority of instances across an entire application at once. Left alone, it becomes an audit finding on every template you own, and remediation gets filed per-page by people who do not know the fix is global — which is the shape of the audit-to-remediation gap that swallows most accessibility budgets.

It also tends to signal other problems. A codebase with no scroll-padding usually also has anchor links that jump under the header, and scrollIntoView() calls that land two lines too high. Fixing 2.4.11 fixes those in the same commit, for free.

This is the class of violation inklu is built for. We scan your HTML, CSS, JSX, and SCSS against WCAG 2.2 using axe-core v4.11 plus our own rules, flag the structural causes — sticky chrome with no reserved scroll padding, overlays that obscure interactive regions — and open a GitHub pull request with the fix. You review the diff like any other PR, and nothing reaches your default branch without your approval. For a criterion like this one, the fix is typically a handful of lines in your global stylesheet plus the token wiring to keep it accurate, which is exactly the kind of change that is faster to review than to write. The token math for scans and fixes is on the pricing page, and the mechanics are in the FAQ.

Tab through your own product before your next release. If the focus ring disappears under your own navigation bar, you have found an AA failure that has probably been there since the header was built.

Book a demo at inklu.io to see it run against your codebase, or email hello@inklu.io if you want to talk through where your sticky UI stands.