Skip to content
fearchitect
UI System-Design Patterns

Modal & Dialog System

Native <dialog> vs portal pattern: focus trap, top-layer, a11y.

By Abas TurabliReviewed

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 angle

Modal 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.

Modal dialog — native <dialog> elementtsx
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

FeatureNative <dialog> + showModal()Custom portal + focus trap
Focus trapBuilt in — no code neededMust query all focusables and intercept Tab
Top-layer stackingAutomatic — above all z-indexSimulate with z-index; breaks in stacking contexts
Backdrop::backdrop pseudo-elementExtra div; must sync visibility with animation
Esc to closeBuilt-in cancel eventMust add keydown listener manually
Entry/exit animationLimited — needs @starting-styleFull control via CSS or Framer Motion
Browser supportBaseline — all modern browsersAny 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.

Further reading

Search fearchitect

Jump to a topic, mode, or action.