The Dependency Problem
Our shared-ui library had a single external runtime dependency beyond React:
focus-trap-react. It was used in exactly one component — the Modal. And
it brought baggage:
- ~15KB added to the bundle (focus-trap + tabbable + focus-trap-react)
- Deprecated context APIs that broke with ESLint 10 and required compat shims
- A mock file (
__mocks__/focus-trap-react.tsx) just to make tests pass - An implicit contract that every consumer of shared-ui also needed this dependency in their bundle
For a library whose guiding principle is "only depend on React and Tailwind CSS," this was a violation hiding in plain sight.
The Native Alternative
Modern browsers give us everything we need for focus trapping. The HTML
<dialog> element, when opened with showModal(), provides:
- Built-in focus trapping — Tab cycles within the dialog automatically
- Inert backdrop — Content behind the dialog becomes non-interactive
- Escape key handling — The
cancelevent fires on Escape - Focus restoration — Focus returns to the trigger element on close
For our React component, the refactored Modal stores a ref to the triggering
element, manages document.body.style.overflow for scroll lock, and uses
keyboard event listeners for Escape:
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const triggerRef = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement;
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
const handleClose = useCallback(() => {
onClose();
if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
}, [onClose]);
// ...
}What We Gained
- Zero runtime dependencies in shared-ui (beyond React peer dep)
- Deleted the mock file — one less testing workaround
- Smaller bundle — ~15KB saved for every page that imports from shared-ui
- ESLint 10 compatibility — no more
fixupPluginRulesshims for this dep - Simpler mental model — the Modal manages its own focus lifecycle
Accessibility Testing
The critical question: does it still work for keyboard and screen reader users? We verified:
- Tab cycling stays within the modal when open
- Shift+Tab wraps correctly at the first focusable element
- Escape closes the modal and returns focus to the trigger
- Click outside the modal closes it
aria-modal="true"androle="dialog"remain on the containeraria-labelledbyconnects the title to the dialog
The existing Playwright E2E tests and Jest unit tests all pass without modification — the behavioral contract is identical.
The Lesson
Before adding a dependency, check what the platform already provides. Focus
trapping was a solved problem in 2020 when <dialog> shipped in all major
browsers. Three years later, we were still bundling a polyfill-style library
for something the browser does natively.
The shared-ui library now has a clean dependency story: React, Tailwind CSS, and nothing else.