# Modal & Dialog System > Native vs portal pattern: focus trap, top-layer, a11y. Frontend architecture guidance from fearchitect, written by Abas Turabli and last reviewed 2026-06-21. Source: https://fearchitect.com/topics/modal-dialog-system Use it as reference for the task at hand. Before changing code, check this guidance against the codebase: where the code already makes a different, deliberate choice, flag the conflict instead of rewriting it. Library APIs move faster than this guide, so confirm exact signatures in the official docs linked at the end. ## Summary The native `` element with `showModal()` gives you a focus trap, top-layer stacking, a `::backdrop` pseudo-element, and Esc-to-close for free — Baseline across modern browsers. A custom portal trades simplicity for animation control. Either way, returning focus to the trigger on close and marking the background `inert` are non-negotiable a11y requirements. ## Modal dialog A modal dialog blocks interaction with the rest of the page until dismissed. The native `` element opened with `showModal()` handles this automatically: it enters the browser's top layer (above every z-index), creates a `::backdrop` pseudo-element, traps Tab/Shift-Tab within itself, and fires a `cancel` event on Esc. It's Baseline — available in all modern browsers. A custom portal adds no feature unavailable natively and requires manual focus management, scroll lock, and top-layer simulation. ## Accessibility requirements — every dialog must satisfy all five - Focus moves into the dialog on open — to the first focusable element or the dialog itself. - Tab/Shift-Tab stays within the open dialog; nothing behind it is reachable by keyboard. - Esc closes the dialog and returns focus to the triggering element. - Dialog has an accessible name via `aria-labelledby` pointing to its visible title element. - Background content is marked `inert` so screen-reader virtual cursor cannot escape the dialog. ## Native with showModal() — React + TypeScript A `useEffect` calls `showModal()` when open turns true, sets `inert` on the body's other children, and restores focus to the trigger ref on close. No external library needed. **Modal dialog — native element** ```tsx import { useEffect, useId, useRef } from "react"; interface ModalProps { open: boolean; title: string; onClose: () => void; children: React.ReactNode; /** ref to the element that triggered the dialog */ triggerRef: React.RefObject; } export function Modal({ open, title, onClose, children, triggerRef }: ModalProps) { const dialogRef = useRef(null); const titleId = useId(); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (open) { dialog.showModal(); // Mark sibling subtrees inert so screen-reader virtual cursor stays inside. const siblings = Array.from(document.body.children).filter( (el) => el !== dialog ); siblings.forEach((el) => el.setAttribute("inert", "")); // Move focus to the first focusable element inside the dialog. const first = dialog.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); first?.focus(); } else { dialog.close(); } return () => { // Always remove inert on cleanup. Array.from(document.body.children).forEach((el) => el.removeAttribute("inert") ); }; }, [open]); // Return focus to trigger when dialog closes. function handleClose() { triggerRef.current?.focus(); onClose(); } return (

{title}

{children}
); } ``` `showModal()` handles the focus trap and Esc natively. The `inert` loop covers screen-reader virtual cursor. `aria-labelledby` links the visible title to the dialog role. ## Native vs custom portal | Feature | Native + showModal() | Custom portal + focus trap | | --- | --- | --- | | **Focus trap** | Built in — no code needed | Must query all focusables and intercept Tab | | **Top-layer stacking** | Automatic — above all z-index | Simulate with z-index; breaks in stacking contexts | | **Backdrop** | ::backdrop pseudo-element | Extra div; must sync visibility with animation | | **Esc to close** | Built-in cancel event | Must add keydown listener manually | | **Entry/exit animation** | Limited — needs @starting-style | Full control via CSS or Framer Motion | | **Browser support** | Baseline — all modern browsers | Any browser with React portals | ## Watch out: Scroll lock: prevent body jump When a modal opens, `overflow: hidden` on `` stops background scroll but shifts layout by the scrollbar width (typically 15–17 px on Windows). Fix: measure `window.innerWidth - document.documentElement.clientWidth` on open and apply that as `padding-right` on ``. Remove both on close. The native `` does not scroll-lock automatically — you still need this. ## Key terms - **top layer**: Browser-managed stacking context above all z-index; `showModal()` and Popover API promote elements into it. - **focus trap**: Tab/Shift-Tab cycles only through focusable elements inside an open modal; background content is unreachable. - **inert attribute**: Marks a subtree non-interactive and invisible to assistive tech without hiding it visually. - **::backdrop**: Pseudo-element rendered behind a top-layer dialog; style it with CSS to dim the page. - **aria-modal**: Tells screen readers the dialog is modal so they restrict virtual-cursor navigation to its content. ## Related topics - [Accessibility (a11y)](https://fearchitect.com/topics/accessibility.md): WCAG 2.2 AA: semantic HTML, keyboard nav, and ARIA done right. - [Component Architecture & Project Structure](https://fearchitect.com/topics/component-architecture.md): Structuring components so composition beats configuration. - [Infinite Scroll & Feeds](https://fearchitect.com/topics/infinite-scroll-feed.md): Cursor-paginated feed with a bounded DOM and accessible fallback. - [Autocomplete / Typeahead](https://fearchitect.com/topics/autocomplete-typeahead.md): Debounced input, AbortController cancellation, and ARIA combobox. - [Data Table / Data Grid](https://fearchitect.com/topics/data-table.md): Headless table logic separate from rendering, with server-side ops for large datasets. - [File Upload](https://fearchitect.com/topics/file-upload.md): Presigned URLs, chunked uploads, progress UI, and retry. ## Further reading - [MDN — : The Dialog element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) - [MDN — HTMLDialogElement: showModal()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) - [ARIA Authoring Practices — Dialog (Modal) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) - [MDN — The inert attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert) - [web.dev — Building a dialog component](https://web.dev/articles/building/a-dialog-component)