Build a Modal Dialog: Focus, ARIA, and Animation

“Build a modal” is one of the most common frontend interview questions. It looks simple. It is not. The interview probes whether you understand focus trapping, ARIA roles, animation choreography, and the dozens of accessibility details that separate a polished modal from a broken one.

Functional requirements

  • Trigger button opens the modal
  • Modal renders on top of page content
  • Click outside (overlay) closes
  • Escape key closes
  • Focus traps inside the modal
  • Returns focus to the trigger on close
  • Animation in/out
  • Screen reader friendly

The native dialog element

Modern browsers have <dialog> with showModal():

<dialog id="myModal">...</dialog>
<script>
document.getElementById('myModal').showModal();
</script>

Built-in: top layer rendering, focus trap, Escape close. Use this if your styling needs allow.

Custom modal architecture

If <dialog> is not enough:

  1. Render to a portal (top-level DOM node, not nested inside content)
  2. Position absolutely; full viewport overlay
  3. Inert content underneath (inert attribute)
  4. Focus trap inside modal
  5. Return focus to trigger on close

Focus trap

When modal is open, Tab cycles between focusable elements inside the modal. Shift+Tab cycles backward.

Implementation:

  • Find first and last focusable element
  • On Tab from last → focus first
  • On Shift+Tab from first → focus last

Use a library: focus-trap, react-focus-lock. Don’t hand-roll unless edge cases require.

Returning focus

On modal close, focus returns to the element that opened it (typically the trigger button). Without this, keyboard users get stranded.

Implementation:

  • Capture document.activeElement when modal opens
  • Call element.focus() on close

ARIA

  • role="dialog" (or role="alertdialog" for urgent confirmations)
  • aria-modal="true"
  • aria-labelledby pointing to the title
  • aria-describedby pointing to the description

Inert background

The inert attribute on the page content makes it non-interactive while the modal is open. Modern browsers support; older browsers need polyfill or alternative.

Without inert, screen readers and tab navigation can leak into background content.

Animation

Common patterns:

  • Fade in overlay (300ms)
  • Slide-up dialog from below or scale in
  • On close: reverse the animation

Animate transform and opacity (compositor-friendly). Avoid animating top/left or width/height.

Click outside to close

Click on overlay → close. Click on dialog → do not close.

Implementation: stopPropagation on dialog click; close handler on overlay click.

Watch out: drag from inside dialog to outside should not close. Track mousedown vs mouseup correctly.

Escape to close

Listen for Escape key. Close modal. Native <dialog> handles automatically.

Stacking modals

If a modal can open another modal, the second covers the first. Each maintains its own focus trap. On close, the deeper modal returns focus to the trigger in the first modal.

Most apps avoid stacking — UX gets confusing.

Mobile-specific

  • Bottom sheets (iOS-style) instead of centered modals
  • Swipe-down-to-close gesture
  • Full-screen on small viewports

Common mistakes

  • No focus trap — keyboard users tab into background content
  • No focus return — closing modal strands the user
  • Background not inert — screen readers read the wrong content
  • Click handler on dialog itself triggers close
  • Animation does not respect prefers-reduced-motion

Frequently Asked Questions

Should I use the native dialog element?

Yes when possible. Native handles many edge cases. Fall back to custom for cases where styling or behavior needs more control.

How do I handle a confirmation dialog within a modal?

Stack two dialogs. The inner is role=”alertdialog”. Trap focus in the inner; return to the outer on close.

How does this work with React?

Use a portal (createPortal) to render the modal at the top of the DOM tree. Use a library (Radix Dialog, React Aria) for accessibility heavy lifting.

Scroll to Top