The dependency problem
Beyond React, the shared-ui library had exactly one external runtime dependency:
focus-trap-react, and it earned its keep in exactly one component, the Modal.
For that one job it brought a lot of 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 whole guiding principle is "only depend on React and Tailwind CSS," this was a violation hiding in plain sight.
The native alternative
Modern browsers already hand me everything I need for focus trapping. Open the
HTML <dialog> element with showModal() and you get:
- 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
On the React side, the refactored Modal stores a ref to the triggering element,
manages document.body.style.overflow for scroll lock, and listens for Escape
with a keyboard handler:
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 I gained
- Zero runtime dependencies in shared-ui (beyond the React peer dep)
- Deleted the mock file, so there's one less testing workaround
- Smaller bundle: ~15KB saved on every page that imports from shared-ui
- ESLint 10 compatibility: no more
fixupPluginRulesshims for this dep - Simpler mental model: the Modal owns its own focus lifecycle
Accessibility testing
The part that actually mattered was whether it still works for keyboard and screen reader users, so I 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 a single change. The behavioral contract is identical.
The lesson
Before reaching for a dependency, check what the platform already gives you. Focus
trapping was a solved problem back in 2020 when <dialog> shipped in every major
browser, yet three years on, shared-ui was 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.
