Skip to main content
Frontend6 min readOctober 30, 2025

Modal Dialogs Done Right: Accessibility and UX

Build modal dialogs that are accessible, performant, and user-friendly — focus trapping, keyboard handling, animation, and the native dialog element.

James Ross Jr.
James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Modal dialogs are everywhere in web applications and are consistently implemented wrong. The list of requirements for a correct modal is longer than most developers expect: focus trapping, scroll locking, escape key handling, backdrop click behavior, screen reader announcements, return focus on close, animation without layout thrashing, and proper stacking when multiple modals open simultaneously.

The native HTML <dialog> element handles many of these requirements automatically. Yet most codebases still use custom div-based modals that reimplement the same behavior poorly. Here is how to build modals that work correctly for everyone.

The Native Dialog Element

The <dialog> element, opened with showModal(), provides focus trapping, backdrop rendering, escape key handling, and top-layer stacking out of the box. These are the features that custom implementations spend dozens of lines recreating:

<script setup lang="ts">
const dialogRef = ref<HTMLDialogElement>()

Function open() {
 dialogRef.value?.showModal()
}

Function close() {
 dialogRef.value?.close()
}
</script>

<template>
 <button @click="open">Open dialog</button>

 <dialog
 ref="dialogRef"
 class="rounded-lg p-0 shadow-xl backdrop:bg-black/50"
 @close="handleClose"
 >
 <div class="p-6">
 <h2 id="dialog-title" class="text-lg font-semibold">Confirm Action</h2>
 <p class="mt-2 text-neutral-600">Are you sure you want to proceed?</p>
 <div class="mt-6 flex justify-end gap-3">
 <button @click="close" class="px-4 py-2 text-neutral-700">Cancel</button>
 <button @click="confirm" class="rounded bg-brand-600 px-4 py-2 text-white">
 Confirm
 </button>
 </div>
 </div>
 </dialog>
</template>

When opened with showModal(), the dialog is placed in the browser's top layer — above everything else on the page, regardless of z-index values. This eliminates the stacking context problems that plague custom modals. The backdrop is a pseudo-element (::backdrop) that can be styled with CSS.

The <dialog> element fires a close event when closed by any means — the close() method, the escape key, or form submission with method="dialog". This single event handler covers all close paths, which is cleaner than listening for escape keys and backdrop clicks separately.

Browser support for <dialog> with showModal() is excellent in 2025. All modern browsers support it fully. If you need to support older browsers, the polyfill from Google Chrome Labs covers the gap.

Focus Management

When a modal opens, focus must move into the modal. When it closes, focus must return to the element that triggered it. The native <dialog> handles the first part — showModal() moves focus to the first focusable element inside the dialog, or to the dialog itself if no focusable elements exist.

Return focus requires explicit handling:

let triggerElement: HTMLElement | null = null

Function open(event: Event) {
 triggerElement = event.target as HTMLElement
 dialogRef.value?.showModal()
}

Function handleClose() {
 triggerElement?.focus()
 triggerElement = null
}

Focus trapping — preventing tab navigation from leaving the modal while it is open — is handled automatically by showModal(). The browser constrains the tab order to elements inside the dialog. Custom implementations need to manually trap focus by intercepting tab and shift+tab key events and wrapping focus around the dialog's focusable elements. Using the native element eliminates this complexity.

If the modal contains many interactive elements, set the initial focus deliberately rather than defaulting to the first focusable element. For a confirmation dialog, focusing the cancel button is safer than focusing the destructive action button — it prevents accidental confirmation by users who press enter immediately after the dialog opens.

<dialog ref="dialogRef" @open="focusCancel">
 <!-- ... -->
 <button ref="cancelRef" @click="close">Cancel</button>
</dialog>

Animation Without Jank

Animating dialogs in and out is where most implementations introduce visual bugs. The challenge is that the dialog needs to be in the DOM and visible for the opening animation, but removed or hidden after the closing animation completes.

CSS @starting-style provides a clean solution for entry animations without JavaScript:

dialog[open] {
 opacity: 1;
 transform: translateY(0);
 transition: opacity 200ms ease, transform 200ms ease;
}

@starting-style {
 dialog[open] {
 opacity: 0;
 transform: translateY(8px);
 }
}

Dialog::backdrop {
 opacity: 1;
 transition: opacity 200ms ease;
}

@starting-style {
 dialog::backdrop {
 opacity: 0;
 }
}

Exit animations are harder because the dialog closes (and becomes hidden) immediately when close() is called. To animate the close, you need to run the animation first, then call close() after it completes:

async function animatedClose() {
 const dialog = dialogRef.value
 if (!dialog) return

 dialog.classList.add('closing')
 await new Promise(resolve => {
 dialog.addEventListener('animationend', resolve, { once: true })
 })
 dialog.classList.remove('closing')
 dialog.close()
}

Keep animations short — 150-200ms for modals. Longer animations feel sluggish for UI that the user wants to interact with immediately. The performance implications of heavy animations on dialog elements affect Interaction to Next Paint, which is a Core Web Vital.

Scroll Locking and Backdrop Behavior

When a modal is open, the page behind it should not scroll. The native <dialog> with showModal() prevents interaction with background content but does not prevent scrolling by default. Add scroll locking explicitly:

function open() {
 document.body.style.overflow = 'hidden'
 dialogRef.value?.showModal()
}

Function handleClose() {
 document.body.style.overflow = ''
 triggerElement?.focus()
}

For mobile devices, overflow: hidden on body does not always prevent scroll on iOS Safari. The more solid approach uses position: fixed on the body with the current scroll position preserved:

let scrollPosition = 0

Function lockScroll() {
 scrollPosition = window.scrollY
 document.body.style.position = 'fixed'
 document.body.style.top = `-${scrollPosition}px`
 document.body.style.width = '100%'
}

Function unlockScroll() {
 document.body.style.position = ''
 document.body.style.top = ''
 document.body.style.width = ''
 window.scrollTo(0, scrollPosition)
}

Backdrop click should close the dialog for non-critical modals. For confirmation dialogs or forms with unsaved data, backdrop clicks should either do nothing or prompt the user. The implementation checks whether the click target is the dialog element itself (the backdrop area) rather than its content:

function handleDialogClick(event: MouseEvent) {
 if (event.target === dialogRef.value) {
 close()
 }
}

This works because the dialog element's padding area acts as the backdrop when styled correctly. Clicking inside the content area targets a child element, not the dialog itself.

Modals are deceptively complex. The native <dialog> element handles the hardest parts — focus trapping, stacking context, escape key behavior — and lets you focus on the UX design that makes dialogs useful rather than intrusive. Use it as your default, and only reach for custom implementations when the native element genuinely cannot meet a specific requirement.