Prevent focused elements from being obscured by sticky headers

This is a technique pulled from KnowledgeBase, a digital accessibility repository available through TPGi’s ARC Platform. KnowledgeBase is maintained and consistently updated by our experts, and can be accessed by anyone with an ARC Essentials or Enterprise tier subscription. Contact us to learn more about KnowledgeBase or the ARC Platform.


This article demonstrates a technique for preventing focused elements from being obscured by sticky headers or footers.

If you’d like to check out the final demo first, here are some links:

What’s the problem here?

Using position:sticky is a popular choice for page headers, so they stick to the top of the viewport as the user scrolls down.

But there’s a problem with this.

When keyboard users Tab backwards up the page, the focused element can disappear underneath the sticky header, meaning that a sighted keyboard user can no longer see where the focus is. The same problem can occur with sticky footers, when tabbing forwards.

Although browsers auto-scroll focused elements into the viewport, that doesn’t cater for this situation, because the element is inside the viewport.

This is an accessibility issue for sighted keyboard users, and will fail 2.4.11 Focus Not Obscured (Minimum) in WCAG 2.2.

How can we solve this?

In theory, we could solve this issue with scroll-margin-top, which is one of a number of properties that can define the rest position of snap-scrolled elements. When a focused element is auto-scrolled into the viewport, that’s a snap-scroll event, which means that scroll-margin-top can be used to constrain its resulting position.

So we could just apply that to focused elements, to match the height of the header:

header {
    height: 15rem;
}
main *:focus {
    scroll-margin-top: 15rem;
}

However in practice, it’s not that simple, because:

  • The height of the header is not necessarily known or fixed. Setting a height in rem accounts for direct changes due to font-size or zooming, but it doesn’t account for cases where text wrapping in the header causes the height to change.
  • It doesn’t work with caret browsing in Chrome, Edge, or Firefox.
  • It doesn’t work at all in Safari.

We could solve the first problem by applying scroll-margin-top dynamically, so it’s re-calculated whenever the header height changes (prototype of dynamic scroll-margin). However that doesn’t fix the other two problems.

To deal with them, we’re going to implement the behavior ourselves.

We can implement auto-scroll behavior from a global focus event, that checks for boundary interactions, e.g. the top of a focused element crossing the bottom of the header:

An illustration of a web page which has a wide green rectangle at the top, representing the header. Underneath that is a small gray box with the text "Focused", representing a focused element. The top of the gray element slightly overlaps the bottom of the green header, and the amount of overlap is illustrated by a thick red line.

We can detect those interactions by comparing the values from getBoundingClientRect() (which returns an element’s size and position relative to the current viewport), then manually tweak the scroll position of overlapping elements using scrollBy(). We’ll also use ResizeObserver to monitor the header’s size, so it responds to live changes in things like font size, text spacing, window size or orientation.

Ironically, this means that scroll-margin-top itself is no longer necessary or useful here.

HTML

The HTML for this pattern uses data attributes as configuration values for the scripting.

The only required attribute is to specify which element is the sticky header or footer. You can define one or the other, or both, but the script will throw an error if neither are defined:

<header data-sticky-header> ... </header>

...

<footer data-sticky-footer> ... </footer>

The header element is assumed to be at the top of the page, and is tested for interactions against its bottom edge. The footer is assumed to be at the bottom, and tested against its top edge.

There are three more optional data attributes that can be used to fine-tune how the script behaves. These attributes can be defined on either the header or footer, but they apply to both (i.e. they can’t be separately defined for each element):

<header
    ...
    data-sticky-offset="6"
    data-sticky-media="(min-width: 40rem)"
    data-sticky-selector="main *:focus"
    >
[data-sticky-offset]

Define a manual adjustment to the calculated sticky-margin.

Adding a few pixels here prevents the top of an element’s focus outline being clipped by the header, since the sticky-margin applies to the element’s layout box, and outline is not part of the layout box.

This option is recommended but not required.

[data-sticky-media]

Define a media query for when the scripting should run.

This caters for situations where the header or footer is only sticky at certain breakpoints (e.g. it becomes un-sticky for small-screen or high-zoom layouts). This option is not required because the script’s behavior won’t apply in that situation anyway — if the header isn’t sticky then no boundary interactions will occur.

However it’s more efficient to turn off the focus and ResizeObserver events when they’re not needed, so this option is also recommended but not required.

If the sticky-margin did continue to apply for non-sticky headers, based on the header’s height, then it would waste interaction space that could be critical in smaller viewports or at high zoom. It could potentially even push the focus outside the viewport entirely, if the header is taller than the viewport. But that can’t happen here, because the boundary we evaluate is the header’s bottom edge position, not its height, hence it’s not strictly necessary to filter the behavior with a media query.

[data-sticky-selector]

Define a CSS selector for which elements should have the sticky-margin applied.

This limits the behavior to elements that match the selector (and are outside the header). This option is also not required because the script already evaluates focused elements with contains(), so it only applies to elements outside the header.

This attribute gives you finer control over which elements are matched by the script’s behavior, but there’s usually no need.

If we allowed the sticky-margin to be applied to elements inside the header, then in cases where the page is already scrolled part-way down, and then focus moves directly to an element inside the header (e.g. by pointer focus), tabbing through the header would pull the rest of the page upwards.

CSS

The only relevant CSS is the use of position:sticky on the header.

In this example it’s applied using a media query, only when the viewport width is 40rem or more. This is exactly the same query that’s defined in the header’s [data-sticky-media] attribute (and must be the same if that attribute is defined at all):

header {
    left: 0;
    position: static;
    top: 0;
}
@media (min-width: 40rem) {
    header {
        position: sticky;
    }
}

JavaScript

The core functionality is to evaluate whether the focused element should have a sticky-margin applied, and then work out whether it needs one by checking for boundary interactions, as previously discussed:

const applyStickyMargin = () => {

    const focused = document.activeElement || document.body;

    let applicable = focused !== document.body;
    if(applicable && sticky.header) {
        applicable = !sticky.header.contains(focused);
    }
    if(applicable && sticky.footer) {
        applicable = !sticky.footer.contains(focused);
    }
    if(applicable && sticky.selector) {
        applicable = focused.matches(sticky.selector);
    }

    if(applicable) {

        const edge = {
            header  : (sticky.header ? sticky.header.getBoundingClientRect().bottom + sticky.offset : 0),
            footer  : (sticky.footer ? sticky.footer.getBoundingClientRect().top - sticky.offset : 0)
        };

        const diff = {
            top     : (sticky.header ? focused.getBoundingClientRect().top - edge.header : 0),
            bottom  : (sticky.footer ? focused.getBoundingClientRect().bottom - edge.footer : 0)
        };

        if(diff.top < 0) {
            window.scrollBy(0, diff.top);
        }
        else if(diff.bottom > 0) {
            window.scrollBy(0, diff.bottom);
        }

    }
};

The activeElement property refers to whichever element is currently focused. When the function is called by a focus event, this will always point to the element that fired the event. But when the function is called by a ResizeObserver, it might point to document.body (or it could be null) if no element is currently focused. And if no element is focused, there’s nothing more to do.

Otherwise, we continue to evaluate the focused element by whether it’s outside the header and footer, and whether it matches() the [data-sticky-selector] if one was defined. If the element is applicable, we then use data from getBoundingClientRect() to check if there’s any boundary interaction.

Finally — if there is — we scroll the page by the amount of overlap to bring the focused element fully into view. Header overlap produces a negative diff while footer overlap produces a positive, corresponding with the scrollBy() value it needs.

The applyStickyMargin() function is called from two different events, focus and ResizeObserver, which are abstracted into control functions so we can turn them on and off:

const observer = new ResizeObserver(applyStickyMargin);

const enableStickyMonitors = () => {

    if(sticky.header) { observer.observe(sticky.header); }
    if(sticky.footer) { observer.observe(sticky.footer); }

    document.addEventListener('focus', applyStickyMargin, true);
};

const disableStickyMonitors = () => {

    if(sticky.header) { observer.unobserve(sticky.header); }
    if(sticky.footer) { observer.unobserve(sticky.footer); }

    document.removeEventListener('focus', applyStickyMargin, true);
};

If [data-sticky-media] wasn’t specified, then the enable function is called straight away.

Otherwise, the specified media query is passed to matchMedia(), and the enable function is only called if it matches. We can then bind a change event to the media query object, which will fire whenever the match changes, and use that to call the corresponding enable or disable function:

let matches = true;

if(sticky.media) {

    const query = window.matchMedia(sticky.media);
    matches = query.matches;

    query.addEventListener('change', (e) => {
        if(matches = e.matches) {
            enableStickyMonitors();
        } else {
            disableStickyMonitors();
        }
    });
}

if(matches) {
    enableStickyMonitors();
}

Notes about caret browsing and Safari

It’s not clear why the scroll-margin-top doesn’t work with caret browsing. Almost nothing has been written about caret browsing from a developer perspective, it seems that most don’t even know it exists.

But since scroll margins are irrevocably tied to scroll-snap behavior, a scroll-snap has to happen in order for it to apply, and perhaps caret browsing doesn’t generate the same lower-level event? (It’s not possible to determine this from high-level scroll events, because they don’t tell us what triggered the scroll, or what kind of scroll it is.) Another possibility is that it does generate the same event, but simply doesn’t apply scroll margins.

As for Safari, it only supports scroll-margin within a container that’s explicitly declared as a scroll-snap container, or on elements that are programmatically scrolled into view. (See Safari does not support scroll-margin #4945 if you’re interested in the nitty-gritty of that.)

One possibility then is to declare the whole page as a scroll-snap container, using scroll-snap-type, which also requires scroll-snap-align on the focusable elements:

body {
    scroll-snap-type: y proximity;
}
main *:focus {
    scroll-snap-align: start;
}

That makes it work. However if we do that, then focused elements are snapped to the scroll-margin position all the time, whether or not they would otherwise be obscured. If you were to Tab to an element that’s already half-way down the page, for example, then it would be snapped to the bottom of the header. That’s way too intrusive for this application.

Another possibility is to use a global focus listener only for Safari, that checks for boundary interaction and then calls scrollIntoView() as required. That makes the scroll-margin work the way we want, at least in most cases. It seems to work for elements that have an author-defined layout box (e.g. padding or borders), but not for elements that only have default browser styles.

And it doesn’t solve the caret browsing problem anyway. Although it is quite close to that solution, since the key to both is the focus-triggered boundary checks, in other words, a manual implementation of the desired behavior.

Interaction

There are no unique interactions for this pattern, it runs in the background. It manifests whenever a matched element receives focus, adjusting its scroll position so it can’t go underneath the header or footer.

Browser support

This pattern is supported by all modern browsers, in versions that are at least two years old. This is the standard benchmark we use for design patterns in the KnowledgeBase:

  • Chrome 87 or later
  • Firefox 78 or later
  • Safari 13 or later
  • Edge 87 or later

Internet Explorer is not considered a modern browser, and is not supported.

Assistive technology support

Since the behavior is triggered by focus events, it will continue to work in cases where those events are triggered by something other than keyboard navigation. For example, spoken commands to Speech Recognition software, navigating with a Switch device, or programmatic focus() calls from other scripting.

It won’t be triggered by navigation that doesn’t generate focus events, such as virtual cursor navigation in JAWS and NVDA. However there’s nothing we can do about that (this kind of navigation can’t be detected or coerced by JavaScript), and it won’t make a difference to blind screen reader users anyway, since they’re not affected by the original problem.

CodePen Demo


Update — March 2023: An Alternative Approach

After this article was published, a conversation on the bird site introduced me to an alternative approach. As it turns out, the browser issues with scroll-margin-top don’t affect scroll-padding-top, which is supported by Safari, and continues to work with caret browsing.

In this Sticky headers / footers and focus styles demo by Alastair Campbell, scroll-padding-top is applied to the <html> element, matching the header’s height. This creates a kind of buffer region for browser auto-scroll, where the bottom of the header is effectively the top of the viewport.

Although it’s not safe to assume that the header has a fixed height, so the value of scroll-padding-top needs to be calculated dynamically. It should also handle the possibility that an already-focused element can become obscured, when user settings (such as increasing the font-size) cause the header height to change. And it needs to evaluate whether or not to apply scroll padding at all, by using a media query that only applies if the header is actually sticky (i.e. matching author CSS that uses the same media query).

So I took the core idea and worked it up into a new demo, which calculates and applies the scroll padding via JavaScript, to handle those conditions:

Having done that, we can compare this approach:

  • The advantage of using scroll padding is that it produces more natural scroll-snapping. When the browser needs to scroll a focused element into the viewport, its scroll position is snapped to the middle of the viewport (rather than the bottom of the header), which matches the native behavior of scroll-into-view.
  • The disadvantage of using scroll padding is that there’s no way to specify which elements which will be affected by it, so it will continue to apply to focused elements inside the header itself. Then in cases where the page is already scrolled part-way down, and focus moves directly to an element inside the header, tabbing through the header will pull the rest of the page upwards. I’m not sure if this would ever be a significant problem for users; I think probably not, but I’d be interested to hear views on that.

The scroll padding approach also means that specifying a media query is now required rather than optional. The Sticky Margin pattern doesn’t require that, it’s only a performance optimization. However the scroll padding approach does need an explicit media query, to prevent it from being applied when the header isn’t sticky.

Both approaches retain a common problem, as previously noted, that they’re not triggered by virtual cursor navigation in JAWS. However the scroll padding approach does work with virtual navigation in NVDA.

Resources

Categories: KnowledgeBase Content, Technical, User Experience (UX)

About James Edwards

I’m a web accessibility consultant with around 20 years experience. I develop, research and write about all aspects of accessible front-end development, with a particular specialism in accessible JavaScript. I can also turn my hand to PHP and MySQL when it’s needed. I started my career as an HTML coder, then as a JavaScript developer, but the more I learned, the more I realised just how important it is to consider accessibility. It’s the basic foundation of web development and a fundamental design principle of the web itself. If information is not accessible, then what’s the point of any of it? Coding is mechanics, but accessibility is people, and it’s people that actually matter.