How To Make Modals With The HTML Dialog Tag
Using both Vanilla JS and React
A “modal” is a pop up that a site can use to display more information or interactivity. Usually, if we need one, we build it ourselves. But that can be tricky; the biggest pain points about modals are making sure to show/close them at the right times, the CSS, and blocking interactions from happening outside of it. And they must be accessible of course!
But, did you know that HTML has a tag to make modals for you? The dia
log tag is a super handy element that does everything we need, it’s got a bunch of features and accessibility controls built right in for us. I’m going to explain everything in vanilla JS/HTML/CSS and then show how to seamlessly incorporate it into any framework like React at the end.
The basics of making a modal
Here’s our starting code.
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
</main>
<script>
const openBtn = document.querySelector('#open');
</script>
It’s so few lines of JS I’m just going to use a script tag to keep it all in once place. All we need is a button to open our modal for now. Next, we need the dialog
tag. By the way, it’s called dialog because its a window we can use to tell our users them something. Anyway, here’s our modal!
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog>
<h2>I'm a modal!</h2>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
</script>
Ok now if you’re following along…nothing happened. That’s because, by default, a dialog is display: none
. We need some way to show it. Don’t worry, it couldn’t be easier: use the new showModal()
method whenever we click the button:
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<h2>I'm a modal!</h2>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
// now just showModal when we click the open button
openBtn.addEventListener('click', () => modal.showModal());
</script>
That’s it! You just grab the modal element, and then call .showModal()
. Note that I just added an id
to our dialog
make that query simpler.
Closing our modal
This is honestly one of the neatest parts. Power users know that hitting the escape
key is usually how you can exit menus and things. Well guess what? That functionality comes built in to dialogs
. Nothing you need to do at all! This is the power of web standards! But, obviously we do want some way to close our model with a button.
So, here’s another cool thing about dialog
elements: we don’t need any JS to close them. If you include a form
in a modal and set the method
attribute to “dialog” it will automatically close the dialog for you! That aria-label
attribute on the button isn’t technically required, but it’s good for screen readers.
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<!-- this form is all it takes! -->
<form method="dialog"><button aria-label='close'>X</button></form>
<h2>I'm a modal!</h2>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
openBtn.addEventListener('click', () => modal.showModal());
</script>
To clarify, any form inside a dialog
with a method of dialog
will not get
or post
but will instead have its default behavior be to close the dialog. There is a JS way to close forms as well, modal.close()
which we’ll cover later on when we use more in depth forms, so don’t panic!
The point is, we can do a lot of stuff with dialogs before we need to bother much with JS.
The open attribute
And before we move on, you should know there’s an open
attribute will be toggled onto our dialog
tag every time we open it. <dialog open>
, it’s an attribute that has no value, it just exists. You can edit this yourself, but that will actually trigger a slightly different behavior. This article teaches the showModal()
logic, but that behavior is based off the s
how logic.I (and MDN) don’t recommend manipulating this property directly, I’m only bringing it up as it can be useful for CSS stuff.
Understanding and styling the backdrop
Now that we have a functional modal, let’s take a second to talk about the overlay behind it that covers our page. It’s officially called the backdrop
, and it’s what allows us to block interaction with our page while our modal is open. So if a user hits tab
or tries to click any link or button outside, it won’t work. Your keyboard focus should be locked, and every other element on the page will be in an inert
state.
Go ahead, add this interaction button to our site and see that you can’t tab
over to it or click on it when the modal is open:
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<form method="dialog"><button aria-label='close'>X</button></form>
<h2>I'm a modal!</h2>
</dialog>
<!-- untouchable when modal is open! -->
<button onclick="alert('!')">Interact!</button>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
openBtn.addEventListener('click', () => modal.showModal());
</script>
Styling the backdrop
By default, the backdrop
pseudo element has a really simple, transparent, gray style. But we can alter this to be anything we want. Me personally, I like to add a blur and darker color:
dialog::backdrop {
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(0.1rem);
}
That should give a really nice look to it. Now, by default you can still scroll under a modal. Again, personally this doesn’t bother me. You can control this by using classes added to the body with JS or you can use the new has
function in CSS to do this super cool one liner that takes advantage of the open
attribute that only appears when the backdrop
is visible:
body:has(dialog[open]) {
overflow: hidden;
}
Clicking the backdrop to fire `modal.close`
Lastly, a behavior that a lot of people are used to but does not come standard with this modal is the ability to close the it by clicking the backdrop. To accomplish this, you just need to add a simple function:
const modal = document.querySelector('#modal');
const handleBackdropClick = (e) => {
if (!e.target.matches('dialog')) return;
const { top, bottom, left, right } = e.target.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = e;
// Ignore radio button arrow movement "clicks"
// https://github.com/facebook/react/issues/7407
if (mouseX === 0 && mouseY === 0) return;
const clickedOutsideOfModalBox = (
mouseX <= left || mouseX >= right ||
mouseY <= top || mouseY >= bottom
);
if (clickedOutsideOfModalBox) modal.close();
}
modal.addEventListener('click', handleBackdropClick);
We’re adding a click listener to our modal. Which, even if you click the backdrop
pseudo element, will still only give the coordinates of the actual modal box itself. Then we figure out where the mouse clicked, and if it’s outside of the box, we close the modal. We close by calling the close
method on our modal element. Oh and that little check for 0
on x
and y
handles certain inputs triggering click events when a user uses a keyboard to select them.
It’s a cool little function, I’ll leave it out of the next examples to keep things clean for this tutorial, but I recommend adding it to your modals.
Using forms in our modal
It’s true that we have a small form for our “X” button, but It’s a super common use case to have a modal pop up with a real form inside of it to get some information. So let’s make one ourselves!
Simple form with the `close` event
Admittedly, I’ve never bothered with this technique. However, if you just want a super simple form with a few options, there’s a neat way you can handle it: the close
event. Unlike traditional forms, when you use method="dialog"
there’s a returnValue
that you can access on the close
event, which is of course fired whenever we close a modal.
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<form method="dialog"><button aria-label='close'>X</button></form>
<h2>Do you accept?</h2>
<form method="dialog">
<!-- here are our different buttons -->
<button value="yes">Yes</button>
<button value="no">No</button>
</form>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
openBtn.addEventListener('click', () => modal.showModal());
// Here's our new listener
modal.addEventListener('close', (e) => {
console.log('Return value:', e.target.returnValue)
});
</script>
Now, when they click yes
or no
that buttons value gets set to the close event’s e.target.returnValue
. That value can only be set byt the submit button, and it must be a string. So, using a few buttons and a close
event listener is a perfect way to get simple answers. You could also tell if the “X” button was clicked as well, since we didn’t give that button an explicit value.
Note: the
close
event is not cancelable and doesn’t bubble, so no event delegation here, you have to attach the listener directly to the modal.
And despite
e.target.returnValue
coming from the button, thetarget
is the dialog itself.
Form submit event example
If you need to collect some real data in the form, I recommend sticking with the submit
event. Here’s the only weird thing: don’t prevent default behavior. Remember, when the method="dialog"
that just means closing the modal, which is what we want! You do still need to reset the form though (unless you want to save answers, I guess).
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<form method="dialog"><button aria-label="Close">X</button></form>
<h2>New User</h2>
<form id="modal-form" method="dialog">
<label for="username">Username</label>
<input type="text" id="username" name="username" />
<fieldset>
<legend>Skill Level</legend>
<label>
<input type="radio" name="skill" value="beginner" checked />
Beginner
</label>
<label>
<input type="radio" name="skill" value="proficient" />
Proficient
</label>
</fieldset>
<button>Create New User</button>
</form>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
const modalForm = document.querySelector('#modal-form');
openBtn.addEventListener('click', () => modal.showModal());
modalForm.addEventListener('submit', (e) => {
const form = e.target;
const formData = new FormData(form);
console.log(Object.fromEntries(formData));
form.reset();
});
Notice that still we have our first form with the close button, that’s fine! No rule about the number of forms in a modal. Also, I took out the close
event because I don’t need it, but submit
events happen first. Not having that e.preventDefalt()
feels so freeing, and I can’t really explain why.
A cancel button
Sometimes you’ll see modal forms with a “cancel” button next to submit. Just use a non-submitting button, add a click listener to it, and then close
the modal on click:
<main>
<h1>Modal Practice</h1>
<button id='open'>Open Modal</button>
<dialog id="modal">
<form method="dialog"><button aria-label="Close">X</button></form>
<h2>New User</h2>
<form id="modal-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" />
<fieldset>
<!-- the same as before -->
</fieldset>
<button type="button" id='cancel'>Cancel</button>
<button>Create New User</button>
</form>
</dialog>
</main>
<script>
const openBtn = document.querySelector('#open');
const modal = document.querySelector('#modal');
const modalForm = document.querySelector('#modal-form');
const cancelBtn = document.querySelector('#cancel');
openBtn.addEventListener('click', () => modal.showModal());
modalForm.addEventListener('submit', (e) => {
const form = e.target;
const formData = new FormData(form);
console.log(Object.fromEntries(formData));
form.reset();
});
// All this does is fire close modal!
cancelBtn.addEventListener('click', () => modal.close());
</script>
And that’s it! There are a few little minor details and extra features you can read about in the dialog docs, but this is like 99% of all you’ll need to know about modals and forms!
React example
The best thing about web standards is how standard they are. That means they super easily integrate with frameworks. React is the one I know so lets show that, but seriously any web framework can use dialog
because any browser can.
For React, the biggest thing is that you need to use the useRef
hook. Other than that, everything is the same. You can of course control your forms, but I’m not going to bother here so it’s super clear everything is the same.
function App() {
const dialogRef = useRef(null);
const openModal = () => dialogRef.current.showModal();
const closeModal = () => dialogRef.current.close();
const handleBackdropClick = (e) => {
if (!e.target.matches('dialog')) return;
const { top, bottom, left, right } = e.target.getBoundingClientRect();
const { clientX: mouseX, clientY: mouseY } = e;
if (mouseX === 0 && mouseY === 0) return;
const clickedOutsideOfModalBox = (
mouseX <= left || mouseX >= right ||
mouseY <= top || mouseY >= bottom
);
if (clickedOutsideOfModalBox) closeModal();
}
const handleSubmit = (e) => {
const form = e.target;
const formData = new FormData(form);
console.log(Object.fromEntries(formData));
form.reset();
}
const handleClose = () => console.log('Closed!');
return <main>
<h1>Modal Practice</h1>
<button onClick={openModal}>Open Modal</button>
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
onClose={handleClose}
>
<form method="dialog"><button aria-label="Close">X</button></form>
<h2>New User</h2>
<form method="dialog" onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input type="text" id="username" name="username" />
<fieldset>
<legend>Skill Level</legend>
<label>
<input type="radio" name="skill" value="beginner" defaultChecked />
Beginner
</label>
<label>
<input type="radio" name="skill" value="proficient" />
Proficient
</label>
</fieldset>
<button type="button" onClick={closeModal}>Cancel</button>
<button>Create New User</button>
</form>
</dialog>
</main>
}
And there you have it! See how easily it incorporates into React? There’s even an onClose
event and everyhing. So, there’s really no reason not to start using web standard modals on your next project!
Happy coding everyone,
Mike