Modals with the Dialog Element
We maintained a 400-line modal component with focus trap logic, scroll lock, Escape key handling, and aria attributes that drifted out of sync with actual behavior. Then <dialog> landed in all major browsers with built-in focus management, top-layer rendering, and a backdrop pseudo-element. We replaced the custom component with forty lines of HTML and CSS.
Basic structure
<dialog id="confirm-dialog">
<form method="dialog">
<h2>Delete project?</h2>
<p>This action cannot be undone.</p>
<div class="actions">
<button value="cancel">Cancel</button>
<button value="confirm" class="danger">Delete</button>
</div>
</form>
</dialog>
<button onclick="document.getElementById('confirm-dialog').showModal()">
Delete project
</button>
The method="dialog" form closes the dialog when any submit button is clicked. The clicked button's value becomes the dialog's returnValue.
Opening and closing programmatically
const dialog = document.getElementById('confirm-dialog');
document.getElementById('delete-btn').addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
deleteProject();
}
});
// Close on backdrop click
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.close('cancel');
}
});
close() accepts an optional return value accessible via dialog.returnValue.
Styling the dialog and backdrop
dialog {
border: none;
border-radius: 12px;
padding: 24px;
max-width: 480px;
box-shadow: 0 25px 50px rgb(0 0 0 / 0.25);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
dialog[open] {
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
Closed dialogs are hidden with display: none — no need for visibility toggles.
React integration
import { useRef, useEffect } from 'react';
function ConfirmDialog({ open, onClose, onConfirm, title, children }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={ref}
onClose={() => onClose(ref.current?.returnValue)}
>
<form method="dialog">
<h2>{title}</h2>
{children}
<button value="cancel" type="submit">Cancel</button>
<button value="confirm" type="submit">Confirm</button>
</form>
</dialog>
);
}
Sync the React open prop with the native dialog state. Listen to the close event rather than button clicks directly.
Accessibility built-in
The dialog element provides:
- Focus trap — Tab cycles within the dialog during
showModal() - Escape to close — default behavior, cancelable via
cancelevent - Top layer — renders above all other content without z-index wars
- Inert background — page content behind the modal is inert automatically
- Role — implicit
role="dialog"witharia-modal="true"
Add aria-labelledby pointing to the heading for screen reader context:
<dialog aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm deletion</h2>
</dialog>
Preventing accidental close
dialog.addEventListener('cancel', (e) => {
if (hasUnsavedChanges) {
e.preventDefault(); // blocks Escape key close
}
});
The cancel event fires on Escape and can be prevented. For backdrop clicks, check the event target as shown above.
Dialog vs. popover vs. custom
| Approach | Focus trap | Backdrop | Top layer | Best for |
|---|---|---|---|---|
<dialog showModal()> |
Built-in | ::backdrop | Yes | Confirmations, forms |
<dialog popover> |
No | No | Yes | Non-blocking panels |
| Custom div + JS | Manual | Manual | z-index | Legacy browsers |
Use <dialog> for anything that blocks page interaction. Reserve popover for tooltips and dropdown panels.
Stacking dialogs
Nested dialogs (confirm inside a settings modal) work natively — each showModal() call stacks in the top layer. Close inner dialogs first. The outer dialog remains open. Test focus return behavior when the inner dialog closes.
Scroll locking
showModal() prevents body scroll automatically via the :root:has(dialog[open]) behavior in supporting browsers. For show() (non-modal), add overflow: hidden on body manually when needed.
Print styles
Dialogs are hidden in print by default (display: none when closed). If users might print while a dialog is open, add @media print { dialog { display: none !important; } } to avoid printing modal content over the page.
Measuring success in production
Deploy changes behind feature flags when possible so you can compare metrics between control and treatment groups. Use Real User Monitoring to capture performance data from actual devices and network conditions — lab tools alone miss the long tail of user experiences. Set up alerts for regressions: a 10% LCP increase week-over-week warrants investigation before it hits CrUX.
Document your baseline metrics before making changes. Performance work without measurement is guesswork. Share results with the team — concrete numbers ("LCP improved 800ms on mobile") build support for continued investment in web performance and reliability.
Review changes quarterly. Browser updates, new API support, and traffic pattern shifts can obsolete previous optimizations or create new opportunities. What worked in 2024 may not be the best approach in 2026.
Additional production considerations
Teams often underestimate the maintenance cost of performance optimizations. Automate what you can: CI bundle budgets, Lighthouse CI on PRs, and RUM dashboards that alert on regressions. Manual audits don't scale past a handful of pages.
Security and performance intersect more than teams expect. Third-party scripts that hurt INP also expand your attack surface. Self-hosting fonts and critical assets reduces both latency and supply-chain risk. Review every external dependency quarterly — remove what you no longer need.
Accessibility and performance share goals: semantic HTML helps screen readers and gives the browser better rendering hints. Native elements like dialog, popover, and details reduce JavaScript while improving accessibility. Prefer platform features over custom implementations when they meet your requirements.
Mobile users dominate traffic for most sites. Test on real mid-tier Android hardware, not just desktop Chrome. Simulated throttling in DevTools approximates network conditions but not CPU constraints. A fix that helps desktop may be invisible on mobile if the bottleneck is JavaScript execution, not network.
Collaborate with backend teams on TTFB and API response times. Frontend optimizations can't fix a 2-second server response. Set SLAs for API endpoints that feed critical pages and measure them in the same RUM pipeline as Core Web Vitals.
Resources
- MDN: dialog element
- HTML spec: dialog
- Can I use dialog
- Dialog polyfill (GoogleChromeLabs)
- Accessible modal dialog example (W3C)
Frequently asked questions
What is the difference between dialog.show() and dialog.showModal()?
show() opens the dialog as a non-modal window — users can still interact with the page behind it, and there's no backdrop. showModal() puts the dialog in the top layer, adds a ::backdrop pseudo-element, traps focus inside the dialog, and blocks interaction with the rest of the page. Always use showModal() for modal dialogs.
Does the dialog element handle focus management automatically?
Yes. showModal() moves focus to the first focusable element inside the dialog (or the dialog itself if none exist). Tab and Shift+Tab cycle within the dialog. Escape closes it by default. When closed, focus returns to the element that opened the dialog. This covers the WCAG focus requirements that custom modal libraries implement manually.
Can I style the backdrop behind a modal dialog?
Yes. The ::backdrop pseudo-element styles the overlay behind showModal() dialogs. Use backdrop-filter for blur, background for dimming, and animation for enter/exit transitions. The backdrop is part of the top layer and sits above all other page content except other top-layer elements.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →