Sam Thorogood

Inert in HTML

The standard [inert] attribute for HTML is amazing, but it's been behind a flag in Chrome since 2017 and has little to no movement from other browsers. However, it's possible to replicate much of its behavior using Shadow DOM and other quirks of the web platform. Read on to find the few steps to make this work.

Some background though. Inert is useful for a bunch of reasons. It can help you disable forms while they're being submitted, or to stop users accessing a page 'behind' a modal dialog.

And to really specify what it means, here's a list of what I want out of inert:

⚠️ This post has a lot of demos which aren't functional here! If you're reading in your RSS reader, maybe consider opening my blog.

This post is going to cover the various steps to making something, a HTML element subtree, inert: across all evergreens with Shadow DOM support.

Big Challenge

The biggest challenge of inert is that on the web, it's really hard to prevent focus. Even on mobile, where you don't have a keyboard to move focus yourself, elements will take focus when pressed or immediately before they activate (e.g., a click).

And as neither of the focus events—focus and focusin—are cancelable, to actually prevent an element gaining focus, we have to instruct the browser before the user attempts to click on an element (you can't intercept the event and "stop" it).

Step 1: Prevent Keyboard Focus

Shadow DOM has an interesting quirk around tab order. Take a look at this code, which creates a Shadow Root that does… almost nothing:

const untabable = document.getElementById('someElement');
const root = untabable.attachShadow({mode: 'open'});
root.innerHTML = `<slot></slot>`;
untabable.tabIndex = -1;

When run on this HTML, what do you think will happen?

<div id="someElement"><button>Focus me</button></div>

Try clicking on the before and after buttons and using your tab key to navigate—you can't access the middle button. However, the button still responds to pointer events: you can click on it.

Why does this work? Negative tabindex usually indicates to the browser that it should skip an element in its keyboard navigation. However, it conveniently also applies to any children of an elememt that has a Shadow Root.

Awkwardly, this can create elements with an "exposed" tabindex, that ends up being visible. But turns out you can create a Shadow Root inside another Shadow Root, that looks a bit like:

<some-inert-element>
  ::shadow-part
    <div tabindex="-1">
      ::shadow-part
        <slot></slot>
      <slot></slot>
    </div>
</some-inert-element>

…in this way, some-inert-element will never expose its inner workings to the outside world.

Confusingly, a negative tabindex still allows focus that's already within the element to keep moving around. It just prevents new focus from entering the element. Confused? Read on to deal with the other issues…

Step 2: Prevent Mouse Focus & Click

So, we can just set pointer-events: none to prevent an element and its children from being clicked, which includes granting it focus. This is the simplest option and it looks the best, but…

Pointer-Events Only

…any child can reverse this by setting pointer-events: auto on itself. Try it below:

Intercept Mouse Down

Focus will be prevented if we intercept the mousedown event and prevent its default behavior. Interestingly, capture doesn't matter here: focus happens after the mouse is pressed. This works on desktop and mobile, as even though there's no mouse on mobile, mouse events end up being emulated. 🐁

Add Click Prevention

Amusingly, intercepting mousedown will still allow click events to pass through, even though we've already prevented the element from being focused. So to be complete, we need to prevent (and stop propogation of) click too—this time though, use capture: true, so our parent can see and stop the event before it ever gets to the child. Here's the final version:

The last issue here is that :hover and :active can pass through (you can see this on desktop Chrome as the button highlights with your mouse). The element is still technically on the page, and it's still technically pointer-events: auto. This is getting down the rabbit hole though: the element can no longer be focused, and it can no longer be clicked. Hooray! 🎉

Your code to stop mouse focus will end up looking like:

target.style.pointerEvents = 'none';
target.addEventListener('click', (event) => {
  event.preventDefault();
  event.stopImmediatePropagation();
}, {capture: true});
target.addEventListener('mousedown', (event) => event.preventDefault());

Step 3: Prevent Implicit Focus

When you click on a part of a webpage, the browser helpfully says "great. I'll place an implicit focus cursor at that location". What on Earth?! This means that when you click and then press tab, you'll be moved around where your cursor was—even if it was on an unfocusable part of an element (like an image or whitespace). This isn't good when you're clicking into an area that's intended to be inert.

In practice, this means we actually need to move the cursor somewhere else, out of the danger zone. But where—what if no other element is available or should end up having focus?

Basically, you'll have to expand your mousedown handler to look like this:

function clearAndMoveFocusTo(target) {
  // TODO: retain any previous tabIndex in case the target already has one
  target.tabIndex = '0';
  target.focus();
  target.removeAttribute('tabindex');
  document.activeElement?.blur();
}

elementThatShouldBeInert.addEventListener('mousedown', (event) => {
  event.preventDefault();
  clearAndMoveFocusTo(document.body);  // or something else
});

Step 4: Prevent Prior Focus

If we want to make an element inert by disabling access via keyboard or mouse, that's fine. However, it might have had focus before this, and naïvely that focus will persist.

An amazing way to find out whether a subtree has focus is to see whether it matches the CSS selector :focus-within. If you're toggling your special inert on a node, the code might look like:

function setInert(element) {
  element.addEventListener('mousedown', /* more code */);

  if (element.matches(':focus-within')) {
    // This element has focus now, we need to clear it
    clearAndMoveFocusTo(document.body);
  }
}

Why does this work? The :focus-within CSS selector is actually much more powerful than the functions we have immediately in JS. They can tell us whether a single element has focus, but not whether one its children does.

Ah, but you ask, what about document.activeElement—doesn't that tell us which element has focus? Yes, sort of, but it breaks down in a world of Shadow DOM. Let's say you're making part of a Web Component inert, but that Web Component is contained by several others. Now document.activeElement just points to the very top of that chain. But the CSS selector will happily pierce into any Shadow Root to tell us whether anything in there is focused by the user.

Implicit Focus

As above, we have to clear and move the focus somewhere else. You can't just clear it—this leaves the implicit focus cursor around.

Demo

Here's putting it all together with a few inert elements (don't worry, no alert() traps here).

Done

I think focus is pretty interesting and something we as web developers really struggle with. And unlike other blog posts, I'm not ending here by promoting my latest polyfill. These are interesting approaches which, when combined, can create a guarantee for your websites that they work and operate in a certain way.

Twitter at me if this is useful, you have questions, or I've missed something.