Accessible Modals
An accessible modal is a pop-up dialog that traps keyboard focus inside itself while open, closes on the Escape key, returns focus to the element that opened it, and announces itself to screen readers using the native <dialog> element or proper ARIA roles.
Part of the free HTML & CSS course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
By the end of this lesson you'll build a real, keyboard-friendly modal with the native <dialog> element — focus trapped inside, Escape to close, focus returned to the trigger, and the page behind it locked.
What You'll Learn
💡 Real-World Analogy
Think of a modal as the passport-control booth at an airport. While you're at the booth, you can't wander off — the queue behind you is roped off (that's the backdrop ), you can only interact with the booth in front of you ( focus trapping ), and there's one clear way to leave (the officer waves you through, or you step back — that's Escape and the close button ). When you're done, you walk back to exactly where you were standing ( focus return ). The native <dialog> element is a booth with all of that built in. Building a modal out of a plain <div> is roping off the area yourself with no officer, no rope, and no exit sign — you have to construct every safety feature by hand, and beginners always miss one.
Why the native <dialog> element wins
A modal is a window that appears on top of the page and demands attention before you can do anything else. For years, developers built them from a <div> and then had to hand-write four hard things: trapping focus (the highlighted element keyboard users act on), handling the Escape key, adding ARIA roles so screen readers announce it, and dimming the rest of the page. Miss any one and you've shipped an inaccessible modal.
The native <dialog> element does all four for you. When you open it with showModal() the browser traps focus (Tab cycles inside the dialog and never reaches the page), closes it on Escape , exposes role="dialog" and aria-modal="true" automatically, and paints a ::backdrop overlay while making the rest of the page inert (unclickable, untabbable). Your only jobs are to give it an accessible name with aria-labelledby , return focus to the trigger on close, and optionally lock scrolling.
One detail that trips everyone: show() and showModal() are not the same. show() opens a non-modal popup with no backdrop, no focus trap, and no Escape. showModal() is the one that gives you a real, accessible modal — use it every time you mean "modal".
1. The smallest correct modal
Read every comment, then run it. Click Open Modal , press Tab a few times (focus stays inside), and press Escape (it closes). All of that came from <dialog> + showModal() — you wrote almost no behaviour code.
2. Returning focus and locking scroll
This adds the two things <dialog> leaves to you: returning focus to the trigger when the modal closes, and locking background scroll while it's open. Notice the single close event handler does the cleanup — so it runs whether the user clicked a button, clicked the backdrop, or pressed Escape.
3. 🎯 Your turn: make it a real modal
Give the dialog an accessible name, open it as a true modal, and close it from the button. Each ___ has a 👉 hint right beside it, and the expected behaviour is in a comment at the bottom.
4. 🎯 Your turn: focus return + form auto-close
Make the form close the dialog on submit with method="dialog" , then return focus to the trigger in the close handler. Check your work against the ✅ Expected comment.
5. Mini-challenge: a delete confirmation
No blanks this time — just a comment outline. Build a "Delete item?" modal from scratch: native <dialog> , showModal() , two buttons that close() , focus returned on close, and your own ::backdrop styling. The expected behaviour is listed in the comments.
When to reach for a modal
- Confirmations: "Are you sure you want to delete?" — force users to acknowledge destructive actions.
- Quick forms: "Add item", "Edit profile" — keep users in context without a full page navigation.
- Media previews: image lightboxes and video players that overlay the current page.
- Don't use for: cookie consent (use a banner), routine notifications (use a toast), or content people browse casually.
Common Errors (and the fix)
- Focus escaping to the page. Symptom: pressing Tab moves the highlight onto links behind the modal. Cause: you opened it with show() , or you built it from a <div> . Fix: open a native <dialog> with showModal() — that is what installs the focus trap.
- No Escape handler / Escape does nothing. Symptom: pressing Escape doesn't close the modal. Cause: a custom <div> modal with no keydown listener (or, again, show() ). Fix: use <dialog> + showModal() — Escape works automatically; don't reimplement it.
- Focus not restored on close. Symptom: closing the modal drops the user at the top of the page and they lose their place. Fix: in dialog.addEventListener('close', () => trigger.focus()) , send focus back to the button that opened it.
- Missing accessible name (no aria). Symptom: a screen reader announces "dialog" with no idea what it's for. Cause: no aria-labelledby / aria-label . Fix: add aria-labelledby="heading-id" pointing at the dialog's heading so it has a name.
- Background still scrolls. Symptom: scrolling inside the modal scrolls the page behind it. Fix: add overflow: hidden to <html> on open and remove it on close.
📋 Quick Reference
You want to…
Use
Open a true modal (backdrop + focus trap)
dialog.showModal()
Open a non-modal popup (no backdrop)
dialog.show()
Close it from code or a button
dialog.close()
Close it on a form submit
<form method="dialog">
Give it an accessible name
aria-labelledby="title-id"
Style the dim overlay
dialog::backdrop { … }
Focus the first field on open
autofocus attribute
Restore focus / clean up on close
dialog.addEventListener('close', …)
Lock background scrolling
html { overflow: hidden }
Frequently Asked Questions
🎉 Lesson Complete
- ✅ A modal is a window that traps attention; the native <dialog> is the accessible way to build one
- ✅ showModal() gives you focus trapping, Escape-to-close, ARIA roles, and a ::backdrop for free
- ✅ show() is only a popup — for real modals always use showModal()
- ✅ Add aria-labelledby pointing at the heading so it has an accessible name
- ✅ Return focus to the trigger in the close event, and lock <html> scroll while open
- ✅ <form method="dialog"> closes the dialog automatically on submit
- ✅ Next lesson: CSS Logical Properties for international layouts
Practice quiz
Which method opens a native dialog as a true modal?
- dialog.show()
- dialog.open()
- dialog.showModal()
- dialog.toggle()
Answer: dialog.showModal(). showModal() gives a backdrop, focus trapping, Escape-to-close, and the top layer. show() opens a non-modal popup with none of those.
What is the difference between show() and showModal()?
- They are identical
- show() traps focus; showModal() does not
- showModal() adds a backdrop, focus trap and Escape; show() is a non-modal popup with none of those
- show() renders in the top layer; showModal() does not
Answer: showModal() adds a backdrop, focus trap and Escape; show() is a non-modal popup with none of those. showModal() opens a real modal (backdrop, focus trap, Escape, top layer). show() is only a non-modal popup.
Which features does a native dialog opened with showModal() provide for free?
- Focus trapping, Escape-to-close, role="dialog"/aria-modal, and a ::backdrop
- Background scroll locking
- Automatic form validation
- A close button
Answer: Focus trapping, Escape-to-close, role="dialog"/aria-modal, and a ::backdrop. showModal() installs the focus trap, Escape handling, the dialog ARIA role/aria-modal, and paints the ::backdrop overlay automatically.
Do you need to add role="dialog" and aria-modal="true" yourself on a native dialog?
- Yes, always
- No — a dialog opened with showModal() exposes them automatically
- Only role="dialog" is automatic
- Only in older browsers
Answer: No — a dialog opened with showModal() exposes them automatically. A native dialog opened with showModal() already exposes role="dialog" and aria-modal="true". You only add an accessible name.
How do you give a dialog an accessible name from its heading?
- aria-describedby pointing at the heading
- aria-labelledby pointing at the heading's id
- title attribute on the dialog
- role="heading" on the dialog
Answer: aria-labelledby pointing at the heading's id. Add aria-labelledby pointing at the heading's id (or aria-label if there is no visible heading) so the dialog has a name.
Why should focus return to the trigger button when a modal closes?
- To reset the page scroll position
- So keyboard and screen-reader users keep their place in the focus order
- To re-run the open animation
- To clear the form fields
Answer: So keyboard and screen-reader users keep their place in the focus order. Returning focus to the trigger keeps keyboard/screen-reader users exactly where they were instead of dropping them at the top of the page.
What closes a native dialog automatically when a form inside it submits?
- form action="close"
- form method="dialog"
- form type="modal"
- form onsubmit="close"
Answer: form method="dialog". A form with method="dialog" closes the dialog on submit and fires its close event.
Which CSS pseudo-element styles the dim layer behind a modal dialog?
- dialog::before
- dialog::backdrop
- dialog::overlay
- dialog::after
Answer: dialog::backdrop. ::backdrop is the layer painted behind a dialog opened with showModal(); you can dim or blur it.
How do you stop the page behind the modal from scrolling?
- Add overflow: hidden to html while the modal is open and remove it on close
- Set the dialog to position: fixed
- Add a high z-index to the dialog
- Use aria-modal="true"
Answer: Add overflow: hidden to html while the modal is open and remove it on close. Scroll locking is the one piece dialog leaves to you: add overflow: hidden to html on open and remove it on close.
Why does Escape not close a custom div-based modal by default?
- Divs cannot receive keyboard events
- A plain div has no built-in Escape handling — only a native dialog opened with showModal() gives it for free
- Escape only works with role="dialog"
- The browser blocks Escape inside divs
Answer: A plain div has no built-in Escape handling — only a native dialog opened with showModal() gives it for free. A custom div modal needs a manual keydown listener. A native dialog opened with showModal() closes on Escape with no extra code.
Continue this course
- Previous: Custom Scrollbars
- Next: Logical Properties