Replace JavaScript Dialogs With the New HTML Dialog Element

Avatar of Mads Stoumann
Mads Stoumann on (Updated on )

You know how there are JavaScript dialogs for alerting, confirming, and prompting user actions? Say you want to replace JavaScript dialogs with the new HTML dialog element.

Let me explain.

I recently worked on a project with a lot of API calls and user feedback gathered with JavaScript dialogs. While I was waiting for another developer to code the <Modal /> component, I used alert(), confirm() and prompt() in my code. For instance:

const deleteLocation = confirm('Delete location');
if (deleteLocation) {
  alert('Location deleted');
}

Then it hit me: you get a lot of modal-related features for free with alert(), confirm(), and prompt() that often go overlooked:

  • It’s a true modal. As in, it will always be on top of the stack — even on top of that <div> with z-index: 99999;.
  • It’s accessible with the keyboard. Press Enter to accept and Escape to cancel.
  • It’s screen reader-friendly. It moves focus and allows the modal content to be read aloud.
  • It traps focus. Pressing Tab will not reach any focusable elements on the main page, but in Firefox and Safari it does indeed move focus to the browser UI. What’s weird though is that you can’t move focus to the “accept” or “cancel” buttons in any browser using the Tab key.
  • It supports user preferences. We get automatic light and dark mode support right out of the box.
  • It pauses code-execution., Plus, it waits for user input.

These three JavaScripts methods work 99% of the time when I need any of these functionalities. So why don’t I — or really any other web developer — use them? Probably because they look like system errors that cannot be styled. Another big consideration: there has been movement toward their deprecation. First removal from cross-domain iframes and, word is, from the web platform entirely, although it also sounds like plans for that are on hold.

With that big consideration in mind, what are alert(), confirm() and prompt() alternatives do we have to replace them? You may have already heard about the <dialog> HTML element and that’s what I want to look at in this article, using it alongside a JavaScript class.

It’s impossible to completely replace Javascript dialogs with identical functionality, but if we use the showModal() method of <dialog> combined with a Promise that can either resolve (accept) or reject (cancel) — then we have something almost as good. Heck, while we’re at it, let’s add sound to the HTML dialog element — just like real system dialogs!

If you’d like to see the demo right away, it’s here.

A dialog class

First, we need a basic JavaScript Class with a settings object that will be merged with the default settings. These settings will be used for all dialogs, unless you overwrite them when invoking them (but more on that later).

export default class Dialog {
constructor(settings = {}) {
  this.settings = Object.assign(
    {
      /* DEFAULT SETTINGS - see description below */
    },
    settings
  )
  this.init()
}

The settings are:

  • accept: This is the “Accept” button’s label.
  • bodyClass: This is a CSS class that is added to <body> element when the dialog is open and <dialog> is unsupported by the browser.
  • cancel: This is the “Cancel” button’s label.
  • dialogClass: This is a custom CSS class added to the <dialog> element.
  • message: This is the content inside the <dialog>.
  • soundAccept: This is the URL to the sound file we’ll play when the user hits the “Accept” button.
  • soundOpen: This is the URL to the sound file we’ll play when the user opens the dialog.
  • template: This is an optional, little HTML template that’s injected into the <dialog>.

The initial template to replace JavaScript dialogs

In the init method, we’ll add a helper function for detecting support for the HTML dialog element in browsers, and set up the basic HTML:

init() {
  // Testing for <dialog> support
  this.dialogSupported = typeof HTMLDialogElement === 'function'
  this.dialog = document.createElement('dialog')
  this.dialog.dataset.component = this.dialogSupported ? 'dialog' : 'no-dialog'
  this.dialog.role = 'dialog'
  
  // HTML template
  this.dialog.innerHTML = `
  <form method="dialog" data-ref="form">
    <fieldset data-ref="fieldset" role="document">
      <legend data-ref="message" id="${(Math.round(Date.now())).toString(36)}">
      </legend>
      <div data-ref="template"></div>
    </fieldset>
    <menu>
      <button data-ref="cancel" value="cancel"></button>
      <button data-ref="accept" value="default"></button>
    </menu>
    <audio data-ref="soundAccept"></audio>
    <audio data-ref="soundOpen"></audio>
  </form>`

  document.body.appendChild(this.dialog)

  // ...
}

Checking for support

The road for browsers to support <dialog> has been long. Safari picked it up pretty recently. Firefox even more recently, though not the <form method="dialog"> part. So, we need to add type="button" to the “Accept” and “Cancel” buttons we’re mimicking. Otherwise, they’ll POST the form and cause a page refresh and we want to avoid that.

<button${this.dialogSupported ? '' : ` type="button"`}...></button>

DOM node references

Did you notice all the data-ref-attributes? We’ll use these for getting references to the DOM nodes:

this.elements = {}
this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)

So far, this.elements.accept is a reference to the “Accept” button, and this.elements.cancel refers to the “Cancel” button.

Button attributes

For screen readers, we need an aria-labelledby attribute pointing to the ID of the tag that describes the dialog — that’s the <legend> tag and it will contain the message.

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

That id? It’s a unique reference to this part of the <legend> element:

The “Cancel” button

Good news! The HTML dialog element has a built-in cancel() method making it easier to replace JavaScript dialogs calling the confirm() method. Let’s emit that event when we click the “Cancel” button:

this.elements.cancel.addEventListener('click', () => { 
  this.dialog.dispatchEvent(new Event('cancel')) 
})

That’s the framework for our <dialog> to replace alert(), confirm(), and prompt().

Polyfilling unsupported browsers

We need to hide the HTML dialog element for browsers that do not support it. To do that, we’ll wrap the logic for showing and hiding the dialog in a new method, toggle():

toggle(open = false) {
  if (this.dialogSupported && open) this.dialog.showModal()
  if (!this.dialogSupported) {
    document.body.classList.toggle(this.settings.bodyClass, open)
    this.dialog.hidden = !open
    /* If a `target` exists, set focus on it when closing */
    if (this.elements.target && !open) {
      this.elements.target.focus()
    }
  }
}
/* Then call it at the end of `init`: */
this.toggle()

Keyboard navigation

Next up, let’s implement a way to trap focus so that the user can tab between the buttons in the dialog without inadvertently exiting the dialog. There are many ways to do this. I like the CSS way, but unfortunately, it’s unreliable. Instead, let’s grab all focusable elements from the dialog as a NodeList and store it in this.focusable:

getFocusable() {
  return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type=&quot;hidden&quot;]),[tabindex]:not([tabindex=&quot;-1&quot;])')]
}

Next, we’ll add a keydown event listener, handling all our keyboard navigation logic:

this.dialog.addEventListener('keydown', e => {
  if (e.key === 'Enter') {
    if (!this.dialogSupported) e.preventDefault()
    this.elements.accept.dispatchEvent(new Event('click'))
  }
  if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel'))
  if (e.key === 'Tab') {
    e.preventDefault()
    const len =  this.focusable.length - 1;
    let index = this.focusable.indexOf(e.target);
    index = e.shiftKey ? index-1 : index+1;
    if (index < 0) index = len;
    if (index > len) index = 0;
    this.focusable[index].focus();
  }
})

For Enter, we need to prevent the <form> from submitting in browsers where the <dialog> element is unsupported. Escape will emit a cancel event. Pressing the Tab key will find the current element in the node list of focusable elements, this.focusable, and set focus on the next item (or the previous one if you hold down the Shift key at the same time).

Displaying the <dialog>

Now let’s show the dialog! For this, we need a small method that merges an optional settings object with the default values. In this object — exactly like the default settings object — we can add or change the settings for a specific dialog.

open(settings = {}) {
  const dialog = Object.assign({}, this.settings, settings)
  this.dialog.className = dialog.dialogClass || ''

  /* set innerText of the elements */
  this.elements.accept.innerText = dialog.accept
  this.elements.cancel.innerText = dialog.cancel
  this.elements.cancel.hidden = dialog.cancel === ''
  this.elements.message.innerText = dialog.message

  /* If sounds exists, update `src` */
  this.elements.soundAccept.src = dialog.soundAccept || ''
  this.elements.soundOpen.src = dialog.soundOpen || ''

  /* A target can be added (from the element invoking the dialog */
  this.elements.target = dialog.target || ''

  /* Optional HTML for custom dialogs */
  this.elements.template.innerHTML = dialog.template || ''

  /* Grab focusable elements */
  this.focusable = this.getFocusable()
  this.hasFormData = this.elements.fieldset.elements.length > 0
  if (dialog.soundOpen) {
    this.elements.soundOpen.play()
  }
  this.toggle(true)
  if (this.hasFormData) {
    /* If form elements exist, focus on that first */
    this.focusable[0].focus()
    this.focusable[0].select()
  }
  else {
    this.elements.accept.focus()
  }
}

Phew! That was a lot of code. Now we can show the <dialog> element in all browsers. But we still need to mimic the functionality that waits for a user’s input after execution, like the native alert(), confirm(), and prompt() methods. For that, we need a Promise and a new method I’m calling waitForUser():

waitForUser() {
  return new Promise(resolve => {
    this.dialog.addEventListener('cancel', () => { 
      this.toggle()
      resolve(false)
    }, { once: true })
    this.elements.accept.addEventListener('click', () => {
      let value = this.hasFormData ? 
        this.collectFormData(new FormData(this.elements.form)) : true;
      if (this.elements.soundAccept.src) this.elements.soundAccept.play()
      this.toggle()
      resolve(value)
    }, { once: true })
  })
}

This method returns a Promise. Within that, we add event listeners for “cancel” and “accept” that either resolve false (cancel), or true (accept). If formData exists (for custom dialogs or prompt), these will be collected with a helper method, then returned in an object:

collectFormData(formData) {
  const object = {};
  formData.forEach((value, key) => {
    if (!Reflect.has(object, key)) {
      object[key] = value
      return
    }
    if (!Array.isArray(object[key])) {
      object[key] = [object[key]]
    }
    object[key].push(value)
  })
  return object
}

We can remove the event listeners immediately, using { once: true }.

To keep it simple, I don’t use reject() but rather simply resolve false.

Hiding the <dialog>

Earlier on, we added event listeners for the built-in cancel event. We call this event when the user clicks the “cancel” button or presses the Escape key. The cancel event removes the open attribute on the <dialog>, thus hiding it.

Where to :focus?

In our open() method, we focus on either the first focusable form field or the “Accept” button:

if (this.hasFormData) {
  this.focusable[0].focus()
  this.focusable[0].select()
}
else {
  this.elements.accept.focus()
}

But is this correct? In the W3’s “Modal Dialog” example, this is indeed the case. In Scott Ohara’s example though, the focus is on the dialog itself — which makes sense if the screen reader should read the text we defined in the aria-labelledby attribute earlier. I’m not sure which is correct or best, but if we want to use Scott’s method. we need to add a tabindex="-1" to the <dialog> in our init method:

this.dialog.tabIndex = -1

Then, in the open() method, we’ll replace the focus code with this:

this.dialog.focus()

We can check the activeElement (the element that has focus) at any given time in DevTools by clicking the “eye” icon and typing document.activeElement in the console. Try tabbing around to see it update:

Showing the eye icon in DevTools, highlighted in bright green.
Clicking the “eye” icon

Adding alert, confirm, and prompt

We’re finally ready to add alert(), confirm() and prompt() to our Dialog class. These will be small helper methods that replace JavaScript dialogs and the original syntax of those methods. All of them call the open()method we created earlier, but with a settings object that matches the way we trigger the original methods.

Let’s compare with the original syntax.

alert() is normally triggered like this:

window.alert(message);

In our Dialog, we’ll add an alert() method that’ll mimic this:

/* dialog.alert() */
alert(message, config = { target: event.target }) {
  const settings = Object.assign({}, config, { cancel: '', message, template: '' })
  this.open(settings)
  return this.waitForUser()
}

We set cancel and template to empty strings, so that — even if we had set default values earlier — these will not be hidden, and only message and accept are shown.

confirm() is normally triggered like this:

window.confirm(message);

In our version, similar to alert(), we create a custom method that shows the message, cancel and accept items:

/* dialog.confirm() */
confirm(message, config = { target: event.target }) {
  const settings = Object.assign({}, config, { message, template: '' })
  this.open(settings)
  return this.waitForUser()
}

prompt() is normally triggered like this:

window.prompt(message, default);

Here, we need to add a template with an <input> that we’ll wrap in a <label>:

/* dialog.prompt() */
prompt(message, value, config = { target: event.target }) {
  const template = `
  <label aria-label="${message}">
    <input name="prompt" value="${value}">
  </label>`
  const settings = Object.assign({}, config, { message, template })
  this.open(settings)
  return this.waitForUser()
}

{ target: event.target } is a reference to the DOM element that calls the method. We’ll use that to refocus on that element when we close the <dialog>, returning the user to where they were before the dialog was fired.

We ought to test this

It’s time to test and make sure everything is working as expected. Let’s create a new HTML file, import the class, and create an instance:

<script type="module">
  import Dialog from './dialog.js';
  const dialog = new Dialog();
</script>

Try out the following use cases one at a time!

/* alert */
dialog.alert('Please refresh your browser')
/* or */
dialog.alert('Please refresh your browser').then((res) => {  console.log(res) })

/* confirm */
dialog.confirm('Do you want to continue?').then((res) => { console.log(res) })

/* prompt */
dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })

Then watch the console as you click “Accept” or “Cancel.” Try again while pressing the Escape or Enter keys instead.

Async/Await

We can also use the async/await way of doing this. We’re replacing JavaScript dialogs even more by mimicking the original syntax, but it requires the wrapping function to be async, while the code within requires the await keyword:

document.getElementById('promptButton').addEventListener('click', async (e) => {
  const value = await dialog.prompt('The meaning of life?', 42);
  console.log(value);
});

Cross-browser styling

We now have a fully-functional cross-browser and screen reader-friendly HTML dialog element that replaces JavaScript dialogs! We’ve covered a lot. But the styling could use a lot of love. Let’s utilize the existing data-component and data-ref-attributes to add cross-browser styling — no need for additional classes or other attributes!

We’ll use the CSS :where pseudo-selector to keep our default styles free from specificity:

:where([data-component*="dialog"] *) {  
  box-sizing: border-box;
  outline-color: var(--dlg-outline-c, hsl(218, 79.19%, 35%))
}
:where([data-component*="dialog"]) {
  --dlg-gap: 1em;
  background: var(--dlg-bg, #fff);
  border: var(--dlg-b, 0);
  border-radius: var(--dlg-bdrs, 0.25em);
  box-shadow: var(--dlg-bxsh, 0px 25px 50px -12px rgba(0, 0, 0, 0.25));
  font-family:var(--dlg-ff, ui-sansserif, system-ui, sans-serif);
  min-inline-size: var(--dlg-mis, auto);
  padding: var(--dlg-p, var(--dlg-gap));
  width: var(--dlg-w, fit-content);
}
:where([data-component="no-dialog"]:not([hidden])) {
  display: block;
  inset-block-start: var(--dlg-gap);
  inset-inline-start: 50%;
  position: fixed;
  transform: translateX(-50%);
}
:where([data-component*="dialog"] menu) {
  display: flex;
  gap: calc(var(--dlg-gap) / 2);
  justify-content: var(--dlg-menu-jc, flex-end);
  margin: 0;
  padding: 0;
}
:where([data-component*="dialog"] menu button) {
  background-color: var(--dlg-button-bgc);
  border: 0;
  border-radius: var(--dlg-bdrs, 0.25em);
  color: var(--dlg-button-c);
  font-size: var(--dlg-button-fz, 0.8em);
  padding: var(--dlg-button-p, 0.65em 1.5em);
}
:where([data-component*="dialog"] [data-ref="accept"]) {
  --dlg-button-bgc: var(--dlg-accept-bgc, hsl(218, 79.19%, 46.08%));
  --dlg-button-c: var(--dlg-accept-c, #fff);
}
:where([data-component*="dialog"] [data-ref="cancel"]) {
  --dlg-button-bgc: var(--dlg-cancel-bgc, transparent);
  --dlg-button-c: var(--dlg-cancel-c, inherit);
}
:where([data-component*="dialog"] [data-ref="fieldset"]) {
  border: 0;
  margin: unset;
  padding: unset;
}
:where([data-component*="dialog"] [data-ref="message"]) {
  font-size: var(--dlg-message-fz, 1.25em);
  margin-block-end: var(--dlg-gap);
}
:where([data-component*="dialog"] [data-ref="template"]:not(:empty)) {
  margin-block-end: var(--dlg-gap);
  width: 100%;
}

You can style these as you’d like, of course. Here’s what the above CSS will give you:

Showing how to replace JavaScript dialogs that use the alert method. The modal is white against a gray background. The content reads please refresh your browser and is followed by a blue button with a white label that says OK.
alert()
Showing how to replace JavaScript dialogs that use the confirm method. The modal is white against a gray background. The content reads please do you want to continue? and is followed by a black link that says cancel, and a blue button with a white label that says OK.
confirm()
Showing how to replace JavaScript dialogs that use the prompt method. The modal is white against a gray background. The content reads the meaning of life, and is followed by a a text input filled with the number 42, which is followed by a black link that says cancel, and a blue button with a white label that says OK.
prompt()

To overwrite these styles and use your own, add a class in dialogClass,

dialogClass: 'custom'

…then add the class in CSS, and update the CSS custom property values:

.custom {
  --dlg-accept-bgc: hsl(159, 65%, 75%);
  --dlg-accept-c: #000;
  /* etc. */
}

A custom dialog example

What if the standard alert(), confirm() and prompt() methods we are mimicking won’t do the trick for your specific use case? We can actually do a bit more to make the <dialog> more flexible to cover more than the content, buttons, and functionality we’ve covered so far — and it’s not much more work.

Earlier, I teased the idea of adding a sound to the dialog. Let’s do that.

You can use the template property of the settings object to inject more HTML. Here’s a custom example, invoked from a <button> with id="btnCustom" that triggers a fun little sound from an MP3 file:

document.getElementById('btnCustom').addEventListener('click', (e) => {
  dialog.open({
    accept: 'Sign in',
    dialogClass: 'custom',
    message: 'Please enter your credentials',
    soundAccept: 'https://assets.yourdomain.com/accept.mp3',
    soundOpen: 'https://assets.yourdomain.com/open.mp3',
    target: e.target,
    template: `
    <label>Username<input type="text" name="username" value="admin"></label>
    <label>Password<input type="password" name="password" value="password"></label>`
  })
  dialog.waitForUser().then((res) => {  console.log(res) })
});

Live demo

Here’s a Pen with everything we built! Open the console, click the buttons, and play around with the dialogs, clicking the buttons and using the keyboard to accept and cancel.

So, what do you think? Is this a good way to replace JavaScript dialogs with the newer HTML dialog element? Or have you tried doing it another way? Let me know in the comments!