Observe an element's focus-within state

With this short tip, we'll devise a way to tell when a DOM element's focus-within state changes, that is observing whenever the element, or any of its descendants, holds the focus.

You may already be familiar with the the CSS :focus-within pseudo-class that provides such a hook:

/*
Emphasize the container when it has focus within.
*/

.container:focus-within {
outline: 1px dotted currentColor;
}

In JavaScript, you could match that CSS pseudo-class or look at the containment of the active element to check if, at that particular point in time, the focus is within a DOM element:

/*
Test if the container has focus within with either:

* matching the :focus-within CSS pseudo-class
* checking that the active element is contained within
*/

containerElement.matches(':focus-within');
containerElement.contains(document.activeElement);

Those checks are great for one-off tests, but what if we want to observe changes in the element's focus containment? While there are no specific DOM events for it, we'll build our own using the available focus events.

DOM focus events: a refresher

There are four events under the FocusEvent umbrella that tell us when an object obtains or loses focus. Here they are, helpfully categorized like bottled water:

I don't have the exact reasons for why the focus and blur events don't bubble up the DOM tree, but Peter-Paul Koch has a plausible explanation:

The reason focus and blur don’t bubble is that the events mean something quite different on the window and on any other focusable element. These two definitions should not be confused, and therefore the events cannot be allowed to bubble up to the document.

The great thing about all focus events, which makes many scenarios much simpler to manage, is that their relatedTarget property points to the other relevant element. For events related to loss of focus (blur and focusout) the related target is the element that will receive the focus next, if any; for events signifying acquisition of focus (focus and focusin), the related target is the previously focused element, if any.

Here's the sequence of events that gets triggered for when focus shifts from element A to element B:

Order Event .target .relatedTarget
1 blur A B
2 focusout A B
3 focus B A
4 focusin B A

All this being said, let's write some code.

Observing the focus-within state

Whenever the container, or one of its descendants, receives focus, the focusin event bubbles up the DOM tree to the container (and beyond). The event's relatedTarget property points to the previously focused element, if any. To detect when the container first receives focus from outside, we can check for containment:

containerElement.addEventListener('focusin', function(e) {
if (e.currentTarget.contains(e.relatedTarget)) {
/* Focus was already in the container */
} else {
/* Focus was received from outside the container */
}
});

We use the same check for containment to detect when focus leaves the element, this time with the focusout event. Since this event's relatedTarget property now points to where the focus is heading next, the check works the other way around too:

containerElement.addEventListener('focusout', function(e) {
if (e.currentTarget.contains(e.relatedTarget)) {
/* Focus will still be within the container */
} else {
/* Focus will leave the container */
}
});

And that's it! Within a few short lines, we're already handling focus containment changes that cover a whole range of input methods (mouse, touch, keyboard).

Handling focus outside the page

When used as the trigger for hiding pop-ups or drop-downs, the basic focusout implementation can occasionally be too eager: annoyingly, the element disappears before we can inspect it with the browser's developer tools.

This is just one instance of the focus moving outside the web page (without necessarily hiding the page). In these cases we might decide we want to keep the element visible.

In the code sample below, we use the Document.hasFocus() method to detect the lost focus and keep the element visible:

containerElement.addEventListener('focusout', function(e) {
/*
If the document has lost focus,
skip the containment check
and keep the element visible.
*/

if (!document.hasFocus()) {
return;
}
if (!e.currentTarget.contains(e.relatedTarget)) {
hideSelf();
}
});

However, once the element loses focus along with the document, and we just ignore the event and not hide the element, we run into the opposite problem: the element just sits in our face when we get back and poke around the page. It doesn't know we're back.

Before giving away the document's focus, we need to attach a listener for when, if ever, the focus returns. The window focus event — the non-bubbly kind — is perfect here, when paired with one of the two on-demand tests we mentioned in the introduction:

containerElement.addEventListener('focusout', function(e) {
const self = e.currentTarget;
/*
If the document has lost focus,
don't hide the container just yet,
wait until the focus is returned.
*/

if (!document.hasFocus()) {
window.addEventListener('focus', function focusReturn() {
/*
We want the listener to be triggered just once,
so we have it remove itself from the `focus` event.
*/

window.removeEventListener('focus', focusReturn);

/*
Test whether the container is still in the DOM
and whether the active element is contained within.
*/

if (self.isConnected && !self.contains(document.activeElement)) {
hideSelf();
}
});
return;
}
if (!self.contains(e.relatedTarget)) {
hideSelf();
}
});

A couple of notes on this listener:

Fixing some browser quirks

Blurring in iOS Safari

In iOS Safari, tapping on non-focusable elements on the page won't blur the active element. It seems to need a new focus target to which to switch before it gives up its current focus. To make sure that the tap outside to blur functionality works as expected, we can mark the body as focusable with tabindex='-1':

<html lang='en'>
<body tabindex='-1'>

</body>
</html>

Conclusion

You can see the full code in action in the demo below, which compares the CSS :focus-within pseudo-class with the JavaScript implementation: