# Keep the Focused Element on Screen

**MUST** · **ID:** `interactions-focus-not-obscured` · **Category:** interactions
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-focus-not-obscured

> MUST: Reserve sticky header/footer height on the scroll container with `scroll-padding-block` (or `scroll-margin-top` on the items) so a newly focused element is never *entirely* hidden under fixed chrome — WCAG SC 2.4.11 Focus Not Obscured (Minimum), Level AA. A focus ring painted behind a sticky bar is worth as much as no ring at all.

Reserve room for sticky chrome with scroll-padding so focus never lands underneath it

Every other focus rule in this corpus is about the ring existing: draw one, prefer :focus-visible, do not remove the outline. None of them says the focused element must actually be on screen — and a ring you cannot see is worth exactly as much as no ring at all. This is the failure: sticky headers, sticky footers, cookie bars and floating toolbars are painted over the scrollport, while the browser scrolls a newly focused element flush to the scrollport edge, which is precisely where that chrome sits. The component is fully obscured, the SC is failed at Level AA, and nothing in the code looks wrong. The fix is to tell the scroller how much of its own edge is spoken for: scroll-padding-block on the scroll container (or scroll-margin-block on the items) reserves the header and footer height, and every scroll the browser performs — including the implicit one from sequential focus navigation — stops short of it. Note the AA bar is "not entirely hidden": SC 2.4.12 (AAA) raises it to "no part of the focus indicator is hidden", so partial occlusion still passes AA but is worth fixing anyway.

## Rule snippet

```tsx
.scroller { scroll-padding-block: 4rem 3rem; } /* sticky header 4rem, footer 3rem */
```

## Bad — do not do this

`interactions-focus-not-obscured-bad`

```tsx
import { useState } from 'react';

const MAILBOXES = [
  'Inbox',
  'Drafts',
  'Sent',
  'Spam',
  'Archive',
  'Snoozed',
  'Starred',
  'Important',
  'Scheduled',
  'Trash',
];

export function FocusNotObscuredBad() {
  const [focused, setFocused] = useState<string | null>(null);

  return (
    <div className="w-full max-w-sm">
      {/* The scroller has a sticky header AND a sticky footer, but no scroll-padding.
          Sequential focus navigation scrolls the focused row flush against the edge
          of the scrollport — which is exactly where the sticky chrome sits. */}
      <div className="h-48 overflow-y-auto rounded-lg border border-border bg-card">
        <div className="sticky top-0 z-10 border-b border-border bg-card px-3 py-2 text-sm font-medium text-foreground">
          Mailboxes
        </div>

        <ul className="p-2">
          {MAILBOXES.map((name) => (
            <li key={name}>
              <button
                type="button"
                onFocus={() => setFocused(name)}
                onBlur={() => setFocused(null)}
                className="w-full rounded px-3 py-2 text-left text-sm text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                {name}
              </button>
            </li>
          ))}
        </ul>

        <div className="sticky bottom-0 z-10 border-t border-border bg-card px-3 py-2 text-xs text-muted-foreground">
          10 mailboxes
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Keyboard focus is on:{' '}
        <span className="font-medium">{focused ?? 'nothing in the list'}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Click <em>Inbox</em>, then hold Tab. The readout keeps naming rows you cannot
        see: each newly focused row is scrolled flush to the scrollport edge, which
        is underneath the sticky header or the sticky footer. The ring is rendered —
        it is just behind opaque, author-created content.
      </p>

      <p className="mt-2 text-xs text-destructive">
        A focused component is entirely hidden by sticky chrome — WCAG 2.4.11 failure
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-focus-not-obscured-good`

```tsx
import { useState } from 'react';

const MAILBOXES = [
  'Inbox',
  'Drafts',
  'Sent',
  'Spam',
  'Archive',
  'Snoozed',
  'Starred',
  'Important',
  'Scheduled',
  'Trash',
];

export function FocusNotObscuredGood() {
  const [focused, setFocused] = useState<string | null>(null);

  return (
    <div className="w-full max-w-sm">
      {/* scroll-py-12 sets scroll-padding-block on the scrollport. Every scroll that
          the browser performs to bring the focused row into view — including the
          implicit one from sequential focus navigation — now stops 3rem short of
          each edge, which is where the sticky chrome ends. */}
      <div className="h-48 scroll-py-12 overflow-y-auto rounded-lg border border-border bg-card">
        <div className="sticky top-0 z-10 border-b border-border bg-card px-3 py-2 text-sm font-medium text-foreground">
          Mailboxes
        </div>

        <ul className="p-2">
          {MAILBOXES.map((name) => (
            <li key={name}>
              <button
                type="button"
                onFocus={() => setFocused(name)}
                onBlur={() => setFocused(null)}
                className="w-full rounded px-3 py-2 text-left text-sm text-foreground hover:bg-muted focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
              >
                {name}
              </button>
            </li>
          ))}
        </ul>

        <div className="sticky bottom-0 z-10 border-t border-border bg-card px-3 py-2 text-xs text-muted-foreground">
          10 mailboxes
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Keyboard focus is on:{' '}
        <span className="font-medium">{focused ?? 'nothing in the list'}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Click <em>Inbox</em>, then hold Tab. The row the readout names is always the
        row you can see: focus lands clear of the header on the way down and clear of
        the footer on the way back up. Per-row <code>scroll-margin-block</code> works
        just as well when you cannot reach the scroller.
      </p>

      <p className="mt-2 text-xs text-success">
        The focused component is never entirely hidden — its ring is always on screen
      </p>
    </div>
  );
}
```

## References

- [WCAG 2.2: Understanding SC 2.4.11 Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)
- [MDN: scroll-padding](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding)
