Jump to content

HTML inert property and React fallback

Posted in React · 3 minutes read

HTML inert is a relatively new property, but it is supported in all major browsers since April this year.

When you set inert on an element, the browser will ignore all user events on it, including tabbing into elements. It will also hide it from screen readers. I like to think of it as a "reversed focus trap". But we can use it to create focus traps for modals by setting inert on the main content.

This div has inert property set and if your browser supports it, you won't be able to interact with the button and link below.

Nor me!

React fallback component #

Before I learned about inert, I built a relatively simple component to achieve the same result.

Now I prefer to use the native solution, but on most projects, we still have to support older browsers. My approach is to handle it in the wrapper component, which finds all focusable elements and sets tabindex="-1". After mounting, the mutation observer starts listening and re-sets the tabindex when content is changed.

The wrapper itself has aria-hidden="true", which hides it from screen readers.

You can try it out on CodePen and see the source code below:

const focusableElementsSelector = [
  "a[href]",
  "input",
  "select",
  "textarea",
  "button",
  "audio[controls]",
  "video[controls]",
  "details > summary:first-of-type",
  "details",
  "[contenteditable]:not([contenteditable=\"false\"])",
  "[tabindex]:not([tabindex=\"-1\"])"
].join(", ");

const addTabIndex = ($wrapper) => {
  $wrapper.querySelectorAll(focusableElementsSelector).forEach(($element) => {
    $element.setAttribute("tabindex", -1);
  });
};
const removeTabIndex = ($wrapper) => {
  $wrapper.querySelectorAll(focusableElementsSelector).forEach(($element) => {
    $element.removeAttribute("tabindex");
  });
};

const Inert = ({ enabled, children, ...props }) => {
  const wrapperRef = useRef(null);

  const observerRef = useRef(
    new MutationObserver(() => addTabIndex(wrapper.current))
  );

  useEffect(() => {
    if (enabled) {
      if (wrapperRef.current) {
        // Add tabindex
        addTabIndex(wrapperRef.current);
        // Start observing
        observerRef.current.observe(wrapperRef.current, {
          childList: true,
          subtree: true
        });
      }
    } else {
      // Disconnect the mutation observer
      observerRef.current.disconnect();
      // And remove the attributes which we added
      removeTabIndex(wrapperRef.current);
    }
  }, [enabled, wrapperRef.current]);

  return (
    <div
      {...props}
      ref={wrapperRef}
      inert={enabled ? "" : null}
      tabindex={enabled ? -1 : null}
      aria-hidden={enabled}
      style={{
        pointerEvents: enabled ? "none" : null
      }}
    >
      {children}
    </div>
  );
};

Drawbacks / edge cases #

The component uses a pretty simple approach, and it doesn't handle some edge cases:

  • The component doesn't check if elements already have the tabindex explicitly set.
  • When the mutation observer triggers, it goes through all of the elements again.
  • Depending on your needs, you might need to update focusableElementsSelector.

All of that is by design. I wanted to keep it simple to make maintenance easier. I think it is easier to alter specific details on a per-project basis.

For the same reasons, I won't publish it as a npm package. Releasing it as a package would require covering all of these cases and figuring out a flexible public API. I just don't think it is worth the effort. But I do hope you will it useful!