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>
withz-index: 99999;
. - It’s accessible with the keyboard. Press
Enter
to accept andEscape
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 theTab
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
Table of Contents
- 1 A dialog class
- 2 The initial template to replace JavaScript dialogs
- 3 Polyfilling unsupported browsers
- 4 Keyboard navigation
- 5 Displaying the <dialog>
- 6 Hiding the <dialog>
- 7 Where to :focus?
- 8 Adding alert, confirm, and prompt
- 9 We ought to test this
- 10 Async/Await
- 11 Cross-browser styling
- 12 A custom dialog example
- 13 Live demo
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 isopen
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:
this.dialog.setAttribute('aria-labelledby', this.elements.message.id)
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()
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="hidden"]),[tabindex]:not([tabindex="-1"])')]
}
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:
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:
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!