Back to Basics: Modern Modal/Dialog Components

by Hannah Goodridge ~ 8 min read

Like this post?

00

I'm hitting that point in my career where it's tempting to stick with what I know works. You know that feeling when you're in your mid-30s and the pace of tech changes feels relentless? It would be so easy to keep reaching for the same libraries and patterns I've always used.

But I've been making myself carve out time to explore what's actually available in the web platform these days. And wow, I've been missing out on some brilliant stuff that's been quietly landing in browsers while I wasn't paying attention.

So I'm starting this new series called "Back to Basics" where I want to dig into fundamental UI components and show you what modern web standards can do for us. We're going to look at the before and after of common patterns, and honestly, some of these comparisons are quite surprising.

I'm starting with modal dialogs because they're fundamental UI components and everyone's already covered buttons to death.

The Old Way: DIV Soup and JavaScript Gymnastics

You'd start with a div, then another div for the backdrop, then you'd need to manage z-indexes, trap focus, handle escape keys, prevent body scroll... it was a whole ordeal. And don't even get me started on making them accessible.

Here's what I used to build (and what you probably still see in a lot of codebases):

<!-- The old approach --> <div class="modal-backdrop" id="modal-backdrop"> <div class="modal-container"> <div class="modal-header"> <h2>Modal Title</h2> <button class="modal-close" id="modal-close">&times;</button> </div> <div class="modal-body"> <p>This is modal content...</p> </div> <div class="modal-footer"> <button class="btn-cancel" id="btn-cancel">Cancel</button> <button class="btn-confirm" id="btn-confirm">Confirm</button> </div> </div> </div>
/* The CSS nightmare */ .modal-backdrop { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; overflow: auto; } .modal-backdrop.active { display: flex; align-items: center; justify-content: center; } .modal-container { background: white; border-radius: 8px; max-width: 500px; width: 90%; max-height: 90vh; overflow: auto; position: relative; animation: modalSlideIn 0.3s ease-out; } @keyframes modalSlideIn { from { opacity: 0; transform: translateY(-50px); } to { opacity: 1; transform: translateY(0); } }
// The JavaScript complexity class Modal { constructor(modalId) { this.modal = document.getElementById(modalId); this.backdrop = this.modal; this.closeBtn = this.modal.querySelector('.modal-close'); this.cancelBtn = this.modal.querySelector('.btn-cancel'); this.originalFocus = null; this.focusableElements = null; this.bindEvents(); } bindEvents() { // Close on backdrop click this.backdrop.addEventListener('click', (e) => { if (e.target === this.backdrop) { this.close(); } }); // Close on close button this.closeBtn?.addEventListener('click', () => this.close()); this.cancelBtn?.addEventListener('click', () => this.close()); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen()) { this.close(); } }); // Trap focus within modal this.modal.addEventListener('keydown', (e) => this.trapFocus(e)); } open() { this.originalFocus = document.activeElement; this.modal.classList.add('active'); document.body.style.overflow = 'hidden'; // Prevent body scroll // Get focusable elements this.focusableElements = this.modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); // Focus first element if (this.focusableElements.length > 0) { this.focusableElements[0].focus(); } } close() { this.modal.classList.remove('active'); document.body.style.overflow = ''; // Restore body scroll // Return focus to original element if (this.originalFocus) { this.originalFocus.focus(); } } isOpen() { return this.modal.classList.contains('active'); } trapFocus(e) { if (e.key !== 'Tab') return; const firstFocusable = this.focusableElements[0]; const lastFocusable = this.focusableElements[this.focusableElements.length - 1]; if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } } // Usage const myModal = new Modal('modal-backdrop'); document.getElementById('open-modal').addEventListener('click', () => { myModal.open(); });

Looking at this now, there seems to be so much code for something that should be simple. And this doesn't even handle everything! I still needed to add screen reader announcements, proper ARIA attributes, mobile touch handling, animation callbacks, and what happens when you have multiple modals.

It's no wonder we all reached for libraries like Bootstrap Modal or React Modal. But we don't actually need any of this complexity anymore.

The New Way: HTML <dialog> Element

The <dialog> element does all the heavy lifting for us. It's designed specifically for this use case and makes everything so much simpler.

Here's the same functionality using modern HTML:

<!-- The modern approach --> <dialog id="modern-modal"> <form method="dialog"> <header> <h2>Modal Title</h2> <button type="submit" value="close" aria-label="Close modal">&times;</button> </header> <main> <p>This is modal content...</p> </main> <footer> <button type="submit" value="cancel">Cancel</button> <button type="submit" value="confirm">Confirm</button> </footer> </form> </dialog>
/* Much simpler CSS */ dialog { border: none; border-radius: 8px; padding: 0; max-width: 500px; width: 90%; max-height: 90vh; overflow: auto; } dialog::backdrop { background-color: rgba(0, 0, 0, 0.5); } dialog[open] { animation: modalSlideIn 0.3s ease-out; } dialog header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #eee; } dialog main { padding: 1rem; } dialog footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem; border-top: 1px solid #eee; } @keyframes modalSlideIn { from { opacity: 0; transform: translateY(-50px); } to { opacity: 1; transform: translateY(0); } }
// Minimal JavaScript const dialog = document.getElementById('modern-modal'); // Open modal document.getElementById('open-modal').addEventListener('click', () => { dialog.showModal(); }); // Handle form submission (close modal) dialog.addEventListener('close', () => { console.log('Modal closed with:', dialog.returnValue); }); // Optional: Close on backdrop click dialog.addEventListener('click', (e) => { if (e.target === dialog) { dialog.close(); } });

What You Get for Free

The best part about the <dialog> element? All that complex JavaScript I showed you earlier is completely unnecessary. The browser handles it all.

Focus management? Done. The dialog automatically traps focus and returns it to wherever it came from when you close it. I spent days building this behavior manually before.

Accessibility? Built right in. Screen readers understand what a dialog is, all the ARIA attributes are automatic, and keyboard navigation just works. No more forgetting to add aria-hidden to the background content.

The escape key closes it automatically. Tab navigation stays trapped inside. The backdrop gets its own pseudo-element so you don't need to manage z-indexes anymore.

And on mobile? It prevents background scrolling, handles touch events properly, and works with all the device accessibility features I never even knew existed.

Browser Support & Progressive Enhancement

The good news is browser support is excellent! According to Can I Use, the <dialog> element has 94.45% global usage support as of 2024. Here's the breakdown:

  • Chrome/Edge: ✅ Since Chrome 37 (2014)
  • Firefox: ✅ Since version 98 (March 2022)
  • Safari: ✅ Since version 15.4 (March 2022)
  • Mobile support: Full support across iOS Safari, Chrome Mobile, and Android browsers

For the small percentage of users on older browsers, you can detect support and fall back gracefully:

// Progressive enhancement if (typeof HTMLDialogElement === 'function') { // Use native dialog dialog.showModal(); } else { // Fallback to custom modal implementation customModal.open(); }

React Implementation

Since I'm mostly working in React these days, here's how I've been implementing this pattern:

import { useRef, useEffect } from 'react'; function ModernDialog({ isOpen, onClose, title, children }) { const dialogRef = useRef(null); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (isOpen) { dialog.showModal(); } else { dialog.close(); } }, [isOpen]); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; const handleClose = () => onClose?.(dialog.returnValue); const handleBackdropClick = (e) => { if (e.target === dialog) onClose?.(); }; dialog.addEventListener('close', handleClose); dialog.addEventListener('click', handleBackdropClick); return () => { dialog.removeEventListener('close', handleClose); dialog.removeEventListener('click', handleBackdropClick); }; }, [onClose]); return ( <dialog ref={dialogRef}> <form method="dialog"> <header> <h2>{title}</h2> <button type="submit" value="close" aria-label="Close modal"> &times; </button> </header> <main>{children}</main> </form> </dialog> ); } // Usage function App() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> Open Dialog </button> <ModernDialog isOpen={isOpen} onClose={() => setIsOpen(false)} title="Modern Dialog" > <p>This is so much simpler!</p> </ModernDialog> </> ); }

Why This Matters

I've been guilty of reaching for heavy libraries when the platform already had a solution. The <dialog> element has been around for a while, but I only discovered it recently.

If you're interested in learning more about modern HTML elements, check out the excellent resources on MDN's dialog documentation and this comprehensive guide on web.dev about building better modals.

What's Next?

I'm planning to dive into tooltips and popovers next - there's a whole new Popover API that's absolutely brilliant if you haven't seen it yet. We're talking about replacing entire positioning libraries with a few HTML attributes.

Other modern HTML elements I want to explore in this series include:

  • The <details> and <summary> elements for accordion-style components
  • Native form validation with HTML5 input types
  • The <picture> element for responsive images
  • CSS :has() selector for parent-based styling

If you've been using the <dialog> element already, I'd love to hear about your experience. And if you haven't... well, maybe it's time to give it a try? I promise you'll wonder why you waited so long.


This is part 1 of my "Back to Basics" series where I'm exploring how modern web standards are making our lives as developers so much easier.

Read next