avatarMike Cronin

Summarize

How To Make Modals With The HTML Dialog Tag

Using both Vanilla JS and React

An example of a modal we can build!

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 dialog 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 show 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, the target 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

JavaScript
Web Development
Coding
Accessibility
Programming
Recommended from ReadMedium