Summary
The native `<dialog>` 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.
Jump to the interview angleModal dialog
A modal dialog blocks interaction with the rest of the page until dismissed. The native <dialog> 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 <dialog> 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.
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<HTMLElement | null>;
}
export function Modal({ open, title, onClose, children, triggerRef }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(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<HTMLElement>(
'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 (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-modal="true"
onClose={handleClose}
// ::backdrop is styled via CSS — no JS needed.
style={{ padding: 0 }}
>
<div role="document">
<h2 id={titleId}>{title}</h2>
{children}
<button type="button" onClick={handleClose} aria-label="Close dialog">
Close
</button>
</div>
</dialog>
);
}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 <dialog> vs custom portal
| Feature | Native <dialog> + 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 |
Scroll lock: prevent body jump
When a modal opens, overflow: hidden on <body> 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 <body>. Remove both on close. The native <dialog> does not scroll-lock automatically — you still need this.
Interview angle
Interviewers want to know whether you reach for <dialog> first and can name what it gives you, or whether you roll a portal from scratch without justification.
Soundbite: "showModal() promotes the dialog to the top layer and traps focus automatically — the only reasons to build a custom portal are animation control or IE-era constraints that no longer apply."
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.