Blog

Articles

How to build a modal window with HTML, CSS and JS: the dialog element

A modal window is built on the dialog element: the browser dims the page, manages focus and closes the window on Esc. We show the code, a version without JavaScript and browser support.

A modal window in HTML, CSS and JS is built on the dialog element. The showModal() method opens the dialog on top of the page, dims the page and blocks it, and the Esc key closes the dialog with no code of ours. The dialog can also be opened and closed without JavaScript, with the commandfor and command attributes on a button. Closing on a click outside the dialog is turned on by the closedby attribute, and Safari still needs a fallback handler for that.

In short

  • The dialog element has worked in all major browsers since March 2022: Chrome 37, Firefox 98, Safari 15.4.
  • showModal() opens the dialog as modal: on top of the page, with the ::backdrop dimming and closing on Esc. After closing, the browser returns focus to the button that opened the dialog.
  • A button with the commandfor and command="show-modal" attributes opens the dialog without JavaScript. The attributes are supported in Chrome and Edge 135, Firefox 144 and Safari 26.2.
  • closedby="any" closes the dialog on a backdrop click in Chrome and Edge 134 and in Firefox 141. In stable Safari the attribute does not work.
  • The user's answer arrives in the returnValue property. It is worth clearing before every opening.
  • The page under an open dialog keeps scrolling. One CSS rule turns the scrolling off.

How to build a modal window on dialog

A modal window, also called a popup, is put together from a dialog element with the content, a button on the page and a showModal() call on click. While the dialog is closed, the browser does not show it.

<button class="open" type="button">Open the dialog</button>

<dialog class="modal">
  <h2>Modal dialog</h2>
  <p>The page under the dialog is unavailable while it is open. Close it with the button or the Esc key.</p>
  <button class="close" type="button">Close</button>
</dialog>
const modal = document.querySelector('.modal');

document.querySelector('.open').addEventListener('click', () => {
  modal.showModal();
});

document.querySelector('.close').addEventListener('click', () => {
  modal.close();
});

After the showModal() call the browser does by itself what has to be written by hand for a window made of a div:

  • moves the dialog to the top layer, which is drawn above the whole page whatever z-index other blocks have;
  • draws a full-screen dimming under the dialog, the ::backdrop pseudo-element;
  • makes the rest of the page inert: it cannot be clicked, and the Tab key does not reach it;
  • puts focus inside the dialog;
  • closes the dialog on Esc and returns focus to the element the dialog was opened from.

We tested the example in Chrome 154. After the click, focus is on the "Close" button; after Esc it returns to the "Open the dialog" button. A click at the point where the "Open the dialog" button lies under the backdrop does not reach the button.

In the live example the dialog already has a width, padding and a backdrop color. These styles are covered in the next section. The live examples are shared with the Russian version of the article, so the text inside them is in Russian.

penlab.space/pen/EZ90e5Uh

Under the example there is a link to the pen: it opens in the Penlab editor, and the code in it can be changed.

A dialog can be opened in three ways, and only one of them makes it modal:

  • showModal() opens a modal dialog: with a backdrop, a blocked page and closing on Esc.
  • show() opens the dialog without a backdrop and without blocking the page. The Esc key does not close such a dialog.
  • The open attribute, set in the markup or added from a script, gives the same non-modal dialog as show().

We do not set or remove open by hand. According to the HTML specification, after open is removed from a modal dialog the close event does not fire, and the page stays blocked.

How to style the dialog and the backdrop

The dialog element comes with browser styles: a solid border, 1em padding, centering through margin: auto and an almost transparent backdrop. Usually we remove the border, set a width and make the ::backdrop background darker.

.modal {
  width: min(90vw, 360px);
  padding: 24px;
  border: 0;
  border-radius: 8px;
}

.modal::backdrop {
  background-color: rgb(0 0 0 / 0.5);
}

.modal h2 {
  margin-top: 0;
}

In the browser styles the backdrop is set to rgba(0, 0, 0, 0.1), that is, black at 10% opacity. On a light page it is barely visible, so we set our own ::backdrop background.

The browser styles most often show themselves in these cases:

  • The dialog has moved to a corner. It is kept in the center by margin: auto from the browser styles. A global margin: 0 reset turns the centering off.
  • A closed dialog is visible on the page. It is hidden by the browser rule dialog:not([open]) { display: none; }. Our display: flex or display: grid on the .modal selector overrides that rule. We write the layout on .modal[open] or on a block inside the dialog.
  • The dialog does not stretch to the full screen. The browser limits the size of a modal dialog: max-width and max-height are calc(100% - 6px - 2em). For a full-screen dialog we override both properties, for example with 100%.

A smooth appearance of the dialog and the backdrop is also written in CSS. We covered that code in the article "Smooth appearance of a block in CSS: how to animate display: none".

How to open and close the dialog without JavaScript

A button is linked to the dialog element by the commandfor and command attributes. The first takes the id of the dialog, the second takes the action: show-modal opens the dialog as modal, close closes it.

<button type="button" commandfor="modal" command="show-modal">Open the dialog</button>

<dialog id="modal" class="modal" closedby="any">
  <h2>A dialog without JavaScript</h2>
  <p>The buttons are linked to the dialog with attributes. Where closedby works, a click on the backdrop closes the dialog too.</p>
  <button type="button" commandfor="modal" command="close">Close</button>
</dialog>

The dialog opens the same way as after showModal(): with a backdrop, a blocked page and closing on Esc. The commandfor and command attributes work in Chrome and Edge from version 135, in Firefox from version 144 and in Safari from version 26.2.

In the live example the dialog has the same styles as in the previous section, and there is no JS panel in it.

penlab.space/pen/ks3C4fsx

A button inside a form needs type="button". If a button with commandfor is inside a form and has no type, the command is not carried out: in Chrome 154 such a button neither closed the dialog nor submitted the form.

In a browser without commandfor support the button has no action: the browser skips an unknown attribute. We did not run the example in such a browser; this follows from the HTML parsing rules. If such browsers visit the site, we add a check and open the dialog from a script:

const modal = document.querySelector('#modal');
const openButton = document.querySelector('[command="show-modal"]');

if (!('commandForElement' in HTMLButtonElement.prototype)) {
  openButton.addEventListener('click', () => {
    modal.showModal();
  });
}

In that case it is more reliable to put the close button into a form with method="dialog": such a form closes the dialog in every browser that has dialog.

<form method="dialog">
  <button>Close</button>
</form>

How to close the dialog on a backdrop click

A click outside the dialog is handled by the closedby attribute of the dialog element. With the value any the dialog closes both on Esc and on a backdrop click. No separate handler is needed.

The attribute has three values:

  • any - both Esc and a click outside the dialog work.
  • closerequest - only Esc and the system ways of closing, for example the Back button on Android. This is how a dialog opened with showModal() behaves when the attribute is not set.
  • none - only our buttons and script can close the dialog.

The closedby attribute works in Chrome and Edge from version 134 and in Firefox from version 141. In Safari it appeared in April 2026 in Technology Preview 242, a preview build of the browser for developers, and it is not in stable Safari 27. For Safari and for older versions of the other browsers we write a fallback handler.

For a backdrop click, event.target is the dialog element itself. A click on the inner padding of the dialog gives the same target. We set the padding of dialog to zero and move the padding to a block inside it, so that any click inside the dialog lands on a nested element. In our example that block is the form with the dialog buttons.

<dialog class="modal">
  <form method="dialog">
    <p>Dialog content</p>
    <button>Close</button>
  </form>
</dialog>
.modal {
  padding: 0;
}

.modal form {
  padding: 24px;
}
const modal = document.querySelector('.modal');

if (!('closedBy' in HTMLDialogElement.prototype)) {
  let pressedOnBackdrop = false;

  modal.addEventListener('pointerdown', (event) => {
    pressedOnBackdrop = event.target === modal;
  });

  modal.addEventListener('click', (event) => {
    if (pressedOnBackdrop && event.target === modal) {
      modal.close();
    }
  });
}

The pointerdown handler is needed for one case. The user selects text in the dialog, drags the mouse past its edge and releases the button over the backdrop. The click event then arrives on dialog, and a handler that checks only event.target will close the dialog. We reproduced this in Chrome 154: the version without pointerdown closed the dialog, the version with it left the dialog open.

We tested both handlers in Chrome 154 on a dialog without the closedby attribute and without the if condition: this browser has the closedBy property, and the code under the condition does not run. We did not run them live in Safari.

How to find out what the user answered

We put the dialog buttons into a form with method="dialog". Such a form is not submitted anywhere: it closes the dialog and writes the value of the pressed button into the returnValue property. We read it in the close event handler.

<dialog class="modal">
  <form method="dialog">
    <h2>Delete the draft?</h2>
    <button value="cancel">Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
const modal = document.querySelector('.modal');

modal.addEventListener('close', () => {
  console.log(modal.returnValue); // 'cancel' or 'delete'
});

The close event fires on any closing: by a form button, by the Esc key, by a backdrop click, by a close() call. The value can also be passed from a script, modal.close('delete'), and through the value of a button with command="close".

There is a nuance here. According to the specification, Esc and a click outside the dialog do not change returnValue; the property keeps the value from the previous closing. We tested this sequence in Chrome 154: pressed "Delete", opened the dialog again and clicked the backdrop. returnValue still held delete, and the page reported a deletion although the user had declined.

With the Esc key, browsers differ. In our test Chrome 154 wrote an empty string into the property after Esc, while a Stack Overflow question describes Firefox 143, which kept the previous value. So that the result does not depend on the browser and the way of closing, we clear the property before every opening:

modal.returnValue = '';
modal.showModal();

Where to put focus and how to give the dialog a name

Without hints the browser puts focus on the first element of the dialog that can be reached with the Tab key. We mark the element we need with the autofocus attribute. In a confirmation dialog that is the cancel button: an accidental press of Enter will not delete anything.

<dialog class="modal" aria-labelledby="modal-title">
  <form method="dialog">
    <h2 id="modal-title">Delete the draft?</h2>
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>

The aria-labelledby attribute links the dialog to its heading, and a screen reader announces the dialog with that text when it opens. The element already has the dialog role, by which a screen reader recognizes the dialog. A dialog opened with showModal() is marked as modal by the browser itself. There is no need to write role="dialog" and aria-modal="true" on dialog. The specification forbids the tabindex attribute on it.

We keep a visible close button in every dialog: not every device has an Esc key. We did not test the examples with screen readers; this section relies on the HTML specification, MDN and the W3C guide on modal dialogs.

How to stop the page from scrolling under the dialog

The showModal() method blocks clicks and focus but leaves page scrolling on. One rule with the :has() selector turns it off: while there is an open modal dialog on the page, html has overflow: hidden.

html:has(dialog:modal) {
  overflow: hidden;
}

In Chrome 154, on a page 3000 pixels high, the mouse wheel over the backdrop scrolled the page by 400 pixels without this rule. With the rule the page stayed in place.

The :has() selector works in Chrome and Edge from version 105, in Firefox from version 121 and in Safari from version 15.4. The :modal pseudo-class works in Chrome and Edge 105, Firefox 103 and Safari 15.6.

The scrollbar disappears together with the scrolling, and where the scrollbar takes up space the page shifts sideways. scrollbar-gutter: stable on the html element reserves the space for the scrollbar. The property is available in Chrome and Edge 94, Firefox 97 and Safari 18.2.

Which browsers support dialog, commandfor and closedby

A basic dialog works in all current browsers. Opening without JavaScript and closing on a backdrop click depend on the version.

The data was collected on October 12, 2026 from the MDN compatibility tables. Stable versions on that day: Chrome and Edge 155, Firefox 157, Safari 27.

  • dialog, showModal(), close(), returnValue, ::backdrop and a form with method="dialog": Chrome 37, Edge 79, Firefox 98, Safari 15.4.
  • The commandfor and command attributes: Chrome and Edge 135, Firefox 144, Safari 26.2.
  • The closedby attribute: Chrome and Edge 134, Firefox 141. In Safari only in the Technology Preview 242 preview build.
  • The :has() selector: Chrome and Edge 105, Firefox 121, Safari 15.4.
  • The :modal pseudo-class: Chrome and Edge 105, Firefox 103, Safari 15.6.

We ran the examples from the article in Chrome 154. We did not test them live in Firefox and Safari; the behavior in those browsers is described from the documentation.

A finished confirmation dialog: the full code

The confirmation dialog is assembled from the parts covered above: a form with method="dialog", focus on the cancel button, closing on a backdrop click with the fallback code, the scroll lock and the answer shown on the page. The dialog is opened by a script, because the script clears returnValue before opening.

<button class="open" type="button">Delete draft</button>
<p class="result">No answer yet.</p>

<dialog class="modal" closedby="any" aria-labelledby="modal-title">
  <form method="dialog">
    <h2 id="modal-title">Delete the draft?</h2>
    <p>It cannot be restored after deletion.</p>
    <div class="actions">
      <button value="cancel" autofocus>Cancel</button>
      <button value="delete">Delete</button>
    </div>
  </form>
</dialog>
.modal {
  width: min(90vw, 360px);
  padding: 0;
  border: 0;
  border-radius: 8px;
}

.modal form {
  padding: 24px;
}

.modal::backdrop {
  background-color: rgb(0 0 0 / 0.5);
}

.modal h2 {
  margin-top: 0;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

html:has(dialog:modal) {
  overflow: hidden;
}
const modal = document.querySelector('.modal');
const result = document.querySelector('.result');

document.querySelector('.open').addEventListener('click', () => {
  modal.returnValue = '';
  modal.showModal();
});

modal.addEventListener('close', () => {
  if (modal.returnValue === 'delete') {
    result.textContent = 'The draft has been deleted.';
  } else {
    result.textContent = 'Deletion cancelled.';
  }
});

// Fallback for browsers without the closedby attribute
if (!('closedBy' in HTMLDialogElement.prototype)) {
  let pressedOnBackdrop = false;

  modal.addEventListener('pointerdown', (event) => {
    pressedOnBackdrop = event.target === modal;
  });

  modal.addEventListener('click', (event) => {
    if (pressedOnBackdrop && event.target === modal) {
      modal.close();
    }
  });
}
penlab.space/pen/FCXHDgMx

This pen lets us check why returnValue needs clearing. We open it in the Penlab editor through the link under the example and repeat the steps:

  1. Remove the line modal.returnValue = ''; from the code.
  2. Press "Delete draft", then "Delete".
  3. Open the dialog again.
  4. Click the backdrop: the page still shows "The draft has been deleted.", although this time we declined. If the line from step 1 is put back, the same click changes the message to "Deletion cancelled.".

A dialog of your own is convenient to build from scratch in a new pen. A finished pen can be shared by a link or embedded on your own page. There is a note about that: "Share a pen and embed it on your site".

FAQ

Is it still worth building a modal window on a div?

In most cases, no. The dialog element has worked in all major browsers since March 2022, while a window on a div needs everything dialog gives at once to be added by hand: blocking the page, managing focus and closing on Esc.

How is dialog different from popover?

A modal dialog blocks the page under it; a block with the popover attribute leaves the page available. For menus, tooltips and dropdown lists popover is the fit. The HTML specification calls building such elements on dialog incorrect.

Why does the dialog not close after close()?

A common cause is a display: flex or display: grid rule on the dialog itself. It overrides the browser rule that hides a dialog without the open attribute. The dialog is closed but stays on screen. We move the layout to the dialog[open] selector or to a block inside the dialog.

How to open and close a modal dialog smoothly?

Smooth opening and closing is done in CSS: the @starting-style rule sets the initial style, and display and overlay in the transition list keep the dialog on screen while it is being hidden. The code for dialog and ::backdrop is in the article "Smooth appearance of a block in CSS: how to animate display: none".

Can closing on Esc be prevented?

Yes, with the closedby="none" attribute in browsers that support it. In Chrome 154 a dialog with this value closed neither on Esc nor on a backdrop click. The second way is to cancel the cancel event with preventDefault(), but according to the specification the cancellation does not always work: if the user did nothing on the page between Esc presses, the browser is allowed to close the dialog. We would keep Esc working wherever closing the dialog breaks nothing.

Can a form with fields be placed in the dialog?

Yes. A form with method="dialog" does not send data to the server: it closes the dialog, the field values stay in the form, and they can be read in the close event handler. In Chrome 154 a field kept the typed text after such a form closed the dialog.

Why does a tooltip or a notification end up under the dialog?

A modal dialog is drawn in the top layer, and the z-index of the other page elements has no effect on that layer. Everything outside the dialog is also inert: it does not respond to clicks or the Tab key. A notification that has to be visible above the dialog goes inside the dialog itself.

Sources