Project 04 · Beginner
Modal Box
A settings card with a button that opens a box on top of the page. You'll build it three ways: a popover with no JavaScript, a modal you put together yourself, and the browser's own <dialog>, which does most of that work for you.
- 5 HTML ideas
- 6 CSS ideas
- 7 JavaScript ideas
- 3 ways to build it
Why build it
Once you can open a box over the page, move focus into it, and lock everything behind it, you can build cookie banners, "Are you sure?" delete confirmations, sign-in prompts, and image lightboxes that keyboard and screen reader users can use and close. You'll also know what the dialog element does for you, and when to reach for it.
Skills you'll earn
- Show and hide a box over the page with the popover attribute
- Stop people clicking or tabbing to the page behind a modal, with inert
- Move focus into a modal when it opens, and back where it was when it closes
- Use the dialog element, and find out which button someone used to close it
01
Try it
Play with it first, then read the code that makes it work.
The popover attribute shows and hides a box with no JavaScript at all. It can't act on a choice, so this one just shows a message.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modal Box</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="settings">
<h1 class="settings-title">Settings</h1>
<p class="status">Notifications are off.</p>
<!-- popovertarget opens the element with that id, with no JavaScript. -->
<button type="button" class="button button-primary" popovertarget="about">
What are notifications?
</button>
</main>
<!-- popover keeps this hidden until a button opens it, then puts it on top. -->
<div
class="modal"
id="about"
popover
role="dialog"
aria-labelledby="about-title"
>
<h2 class="modal-title" id="about-title">About notifications</h2>
<p class="modal-text">
Notifications tell you when something new is out. Press Escape or click
outside this box to close it.
</p>
<div class="modal-actions">
<!-- popovertargetaction="hide" makes this button only ever close it. -->
<button
type="button"
class="button button-primary"
popovertarget="about"
popovertargetaction="hide"
>
Got it
</button>
</div>
</div>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
The popover attribute shows and hides a box with no JavaScript at all. It can't act on a choice, so this one just shows a message.
HTMLthe structure
- 01
Hide a box with the
popoverattributeThe
popoverattribute turns an element into a popover: the browser keeps it hidden until something opens it, then shows it on top of everything else on the page. It also closes it for you when someone presses Escape or clicks outside it. Here it holds the short message about notifications, with no JavaScript at all.<!-- popover keeps this hidden until a button opens it, then puts it on top. --> <div class="modal" id="about" popover role="dialog" aria-labelledby="about-title" > - 02
Open and close it with
popovertargetA button needs to know which popover it controls.
popovertargetpoints it at one by itsid, hereabout, and each click opens or closes that popover. Addingpopovertargetaction="hide"changes what the button does, so the Got it button only ever closes the popover.<!-- popovertarget opens the element with that id, with no JavaScript. --> <button type="button" class="button button-primary" popovertarget="about"> What are notifications? </button> </main> <!-- popover keeps this hidden until a button opens it, then puts it on top. --> <div class="modal" id="about" popover role="dialog" aria-labelledby="about-title" > <h2 class="modal-title" id="about-title">About notifications</h2> <p class="modal-text"> Notifications tell you when something new is out. Press Escape or click outside this box to close it. </p> <div class="modal-actions"> <!-- popovertargetaction="hide" makes this button only ever close it. --> <button type="button" class="button button-primary" popovertarget="about" popovertargetaction="hide" > Got it </button>
CSSthe look
- 03
Know what a popover doesn't do
::backdropis a layer the browser puts behind an open popover, and this CSS uses it to dim the page. But a popover isn't a modal: Tab can still leave it and reach the button behind it. That's fine for a message someone reads and dismisses, not for a question that needs an answer./* An open popover gets a ::backdrop too, but the page behind still works. */ .modal::backdrop { background: rgb(28 28 34 / 0.6); }
HTMLthe structure
- 01
Say it's a modal with
role="dialog"To the browser, this modal is only a
<div>, which says nothing about what it is.roletells screen readers what an element is for:role="dialog"says it's a dialog, andaria-modal="true"says the page behind can't be used while it's open.aria-labelledbypoints at the heading'sid, so the dialog is announced by its title.<!-- The overlay covers the page; hidden keeps it away until it's opened. --> <div class="overlay" id="overlay" hidden> <!-- role="dialog" and aria-modal tell screen readers this is a modal. --> <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" >
CSSthe look
- 02
Cover the screen with
position: fixedposition: fixedplaces an element against the browser window rather than the page, so it stays put when the page scrolls.inset: 0stretches it to all four edges, so the overlay covers the whole screen. Grid withplace-items: centerputs the box in the middle, and the see-through dark background dims the page behind./* position: fixed with inset: 0 stretches the overlay over the whole screen. */ .overlay { position: fixed; inset: 0; display: grid; place-items: center; padding: 1rem; background: rgb(28 28 34 / 0.6); } - 03
Don't let
displayundohiddenThe
hiddenattribute hides an element because the browser's own styles give itdisplay: none. Your styles win over the browser's, so thedisplay: gridon.overlaywould keep it visible even withhiddenset. The.overlay[hidden]rule matches only while the attribute is there, and putsdisplay: noneback./* display: grid above would beat hidden, so hidden needs its own rule. */ .overlay[hidden] { display: none; }
JavaScriptthe behaviour
- 04
Lock the page behind with
inertA modal should be the only thing someone can use while it's open. Setting
inerttotrueon an element makes everything inside it unclickable, skipped by the Tab key, and hidden from screen readers.openModal()sets it on the<main>page behind the modal, so keyboard users can't wander out, andcloseModal()sets it back tofalse.function openModal() { overlay.hidden = false; // inert makes the page behind unclickable, and the Tab key skips it. page.inert = true; // Move focus into the modal, so keyboard users start there. notNowButton.focus(); } function closeModal() { overlay.hidden = true; page.inert = false; - 05
Move focus in, then back out
Focus marks the element the keyboard is acting on, and
focus()moves it there from your code. When the modal opens, focus goes to the Not now button, so keyboard and screen reader users start inside the modal. When it closes, focus goes back to the button that opened it, so no one loses their place on the page.// Move focus into the modal, so keyboard users start there. notNowButton.focus(); } function closeModal() { overlay.hidden = true; page.inert = false; // Send focus back to the button that opened it. openButton.focus(); } - 06
Close on Escape with
keydownPeople expect Escape to close a modal. A
keydownlistener runs every time a key is pressed, andevent.keysays which one. The listener sits on the wholedocument, so it hears Escape wherever focus is. The!overlay.hiddencheck makes sure it only closes the modal while it's open.// Escape should close it, but only while it's open. document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !overlay.hidden) { closeModal(); } }); - 07
Close on a click outside with
event.targetA click on the box inside the overlay also reaches the overlay's click listener, because the box sits inside it.
event.targetis the element that was actually clicked. If it's the overlay itself and not something in the box, the click landed on the dark area outside, so the modal closes.// A click on the dark overlay, not the box inside it, closes it. overlay.addEventListener("click", (event) => { if (event.target === overlay) { closeModal(); } });
HTMLthe structure
- 01
Use the
<dialog>element<dialog>is the browser's own element for boxes like this one. Opened as a modal, it blocks the page behind, closes on Escape, and sends focus back to the button that opened it when it closes. That's theinert,focus(), andkeydowncode from Build it yourself, built in.aria-labelledbystill names it after its heading.<!-- <dialog> keeps focus inside and closes on Escape, with no extra code. --> <dialog class="modal" id="modal" aria-labelledby="modal-title"> <div class="modal-content"> <h2 class="modal-title" id="modal-title">Turn on notifications?</h2> <p class="modal-text"> We'll let you know when something new is out. You can change this any time. </p> <!-- method="dialog" closes the dialog and remembers which button was pressed. --> <form method="dialog" class="modal-actions"> <button class="button" value="off">Not now</button> <button class="button button-primary" value="on">Turn on</button> </form> </div> </dialog> - 02
Close with
<form method="dialog">A form usually sends its data somewhere when it's submitted. With
method="dialog", submitting it closes the dialog it's in instead, with no JavaScript needed. The buttons here have notype, so pressing one submits the form. The dialog then keeps that button'svalue,onoroff, so you can tell which one it was.<!-- method="dialog" closes the dialog and remembers which button was pressed. --> <form method="dialog" class="modal-actions"> <button class="button" value="off">Not now</button> <button class="button button-primary" value="on">Turn on</button> </form>
CSSthe look
- 03
Dim the page with
::backdrop::backdropis a pseudo-element: a part of the page that CSS can style but that isn't in your HTML. The browser puts it between the page and an open modal<dialog>, filling the screen. Giving it a see-through dark background dims the page behind, so there's no overlay<div>to build like in Build it yourself./* ::backdrop is the layer between the page and an open modal. */ .modal::backdrop { background: rgb(28 28 34 / 0.6); } - 04
Keep the padding off the
<dialog>Padding is the space inside an element, around its content. The
<dialog>has none, and the padding lives on.modal-contentinstead, so the content fills the whole dialog. That means a click that lands on the<dialog>itself, not on something inside it, must have come from outside the box, which the JavaScript uses to close it./* No padding on the dialog itself: clicks on it can only come from outside. */ .modal { width: min(26rem, calc(100% - 2rem)); padding: 0; border: 0; border-radius: 1.5rem; box-shadow: 0 20px 50px -20px rgb(0 0 0 / 0.5); } /* ::backdrop is the layer between the page and an open modal. */ .modal::backdrop { background: rgb(28 28 34 / 0.6); } .modal-content { padding: 2rem; } - 05
Stop the page scrolling with
:has():has()matches an element when something inside it matches the selector you give it.body:has(.modal[open])matches the page only while the modal has itsopenattribute, which the browser adds when it opens.overflow: hiddenthen stops the page behind from scrolling, and it scrolls again once the modal closes andopengoes away./* Stop the page behind from scrolling while the modal is open. */ body:has(.modal[open]) { overflow: hidden; }
JavaScriptthe behaviour
- 06
Open it with
showModal()A
<dialog>can open two ways.show()opens it but leaves the page behind usable.showModal()opens it as a modal: it adds the backdrop and blocks everything else until it closes. Just before opening, the code setsreturnValueto an empty string, so the last answer doesn't carry over.openButton.addEventListener("click", () => { // Clear the last answer, so Escape or a click outside counts as no answer. modal.returnValue = ""; // showModal(), not show(): only showModal() blocks the page behind it. modal.showModal(); }); - 07
Close on a click outside
This is the same
event.targetcheck as Build it yourself, but the listener sits on the<dialog>itself. The dialog has no padding, so its content fills it, and a click on the backdrop outside counts as a click on the dialog. So ifevent.targetis the dialog, the click came from outside, andclose()shuts it.// A click outside the content lands on the <dialog> itself. modal.addEventListener("click", (event) => { if (event.target === modal) { modal.close(); } }); - 08
Read the answer in the
closeeventThe
closeevent fires every time the dialog closes, whether by a button, Escape, or a click outside, so the answer is read in one place.returnValueholds the pressed button'svalue. After Escape or a click outside, it's still the empty string set when it opened, so neither branch runs and the status stays as it was.// "close" fires however the modal closed: a button, Escape, or a click outside. modal.addEventListener("close", () => { if (modal.returnValue === "on") { statusMessage.textContent = "Notifications are on."; } else if (modal.returnValue === "off") { statusMessage.textContent = "Notifications are off."; } });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Fade and scale the modal in when it opens, and skip it for reduced motion.
- Challenge 02
Ask for confirmation before something can't be undone, like deleting a note.
- Challenge 03
Remember the answer in localStorage, so the modal only asks once.
Learn more on the blog
Articles that go further into ideas from this project.
Finished Modal Box?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)