You don't notice it when it works. You only notice it when it breaks. You open a modal. You hit Tab. The focus moves to the "Cancel" button. You hit Tab again. It moves to "Confirm". You hit Tab again. And suddenly you are in the URL bar. Or worse, you are highlighting some random link in the footer behind the gray overlay. The illusion is broken. You aren't in a "dialog" anymore. You are just looking at a div with a high z-index.

The Problem

Building a Focus Trap sounds easy. "Just listen for the Tab key," you say. "If they are on the last element, move them to the first." I tried that. It works for about 5 minutes. Then you realize:

  1. What if the user Shift+Tabs (goes backwards)?
  2. What if the user clicks outside the modal?
  3. What if the DOM changes and the "last element" isn't the last element anymore?
  4. What if the first element is disabled?
  5. What if there is an iframe? (The black hole of focus).

It turns into a nightmare of edge cases.

The RxJS Solution

In Werkbank, I use useFocusTrap. And yes, I use RxJS.

"Why not just use react-focus-lock?" It's a great library. But I want to reduce the number of dependencies and I wanted full control over the behavior. I wanted to integrate it with our other hooks like useOnEscape and useOutsideClick.

"Why not just use the inert attribute?" inert is amazing. It lets you disable the entire document except your modal. But to use it, you have to find all the siblings of your modal (and your modal's parents' siblings) and mark them as inert. You have to walk the DOM tree up and down. It's expensive.

Trapping focus is often cheaper than excluding everything else.

"Why not use the native <dialog> element?" You should! <dialog> is fantastic. It handles focus trapping natively when you call .showModal(). But sometimes you need a custom implementation. Maybe you need a specific animation that <dialog> makes hard. Maybe you are building a complex panel system that isn't quite a dialog.

When you do need to roll your own, you need useFocusTrap.

It's not just for modals. In a complex, multi-panel layout (like the one you can build with werkbank/component/grid), you might want to "trap" focus inside the active panel. If I'm editing code in the left pane, pressing Tab shouldn't jump me to the terminal in the bottom pane. It should indent my code. Focus management is about context, not just blocking the user.

Why RxJS? Because focus is a stream. It's not just about trapping the key. It's about trapping the event.

useEffect(() => {
    let subscription = focusin$
        .pipe(
            filter((target) => !currentDialog.contains(target))
        )
        .subscribe((target) => {
            // Shadow DOM support? Use composedPath() here.
            // You tried to leave? Nice try.
            focusFirstDescendant(currentDialog);
        });
    
    return () => subscription.unsubscribe();
}, [currentDialog]);

We listen to focusin globally. If the focus lands anywhere outside our trap, we yank it back. Immediately. And because it's RxJS, cleanup is trivial. When the component unmounts, we unsubscribe. No dangling listeners. No memory leaks. This handles the "click outside" case. It handles the "programmatic focus" case. It handles the "screen reader navigation" case.

A Note on Accessibility: Yanking focus is aggressive. It can be confusing. That's why your modal must have role="dialog" and aria-modal="true". This tells the screen reader, "Hey, I'm a modal, I'm trapping you on purpose." Our JS is just the enforcement mechanism for browsers that don't fully respect that contract yet.

The Algorithm

But where do we yank it back to? We need to find the "First Descendant". But not just the first child. The first focusable child. And "focusable" is a messy concept in HTML.

  • Is it an <a> tag? Only if it has an href.
  • Is it a <button>? Only if it's not disabled.
  • Is it hidden? aria-hidden? display: none?
  • Does it have tabindex="-1"?

We have to traverse the tree. We have to check attributes.

function getFocusable(element: HTMLElement) {
	let focusable = element.querySelectorAll(
		'a[href], button, input, textarea, ...'
	);
    // ... filter out disabled and hidden ...
}

"Wait, isn't querySelectorAll slow?" Yes, if you run it on every keypress. But we don't. We run it lazily, and we can use MutationObserver (another hook!) to invalidate the cache if the DOM changes.

Why It Matters

This is the kind of code that nobody wants to write. It's unglamorous. It's "plumbing". It has zero visual impact. But it's the difference between a "React Component" and a "User Interface Element". A native OS dialog traps focus. A native OS menu manages arrow keys.

If we want the web to feel like an app, we have to respect these rules. We can't just slap a position: fixed on a div and call it a day. We are talking about Modal dialogs here. Modeless dialogs (like a find bar) shouldn't trap focus. But if you block the UI with an overlay, you must block the focus too.

We have to do the invisible work.