Your browser doesn't support some of the latest features used to build this site. For the best experience you might want to look at upgrading your browser.

Skip to main content

Getting started with the HTML5 dialog element

Learn the fundamentals and quirks of working with the <dialog> element – a native modal dialog in the browser.

A brief introduction

Whilst the <dialog> element isn’t new, native browser support only dropped for Google Chrome in the HTML 5.2 update earlier this year.

HTML5 dialog element browser implementation status
Browser Status
Google Chrome Shipped
Safari Not Supported
Firefox Not Supported
Opera In Development
Microsoft Edge Under Consideration
Internet Explorer 11 Not Supported

Although browser support is mostly limited to Google Chrome, we do have a polyfill created by Google that we can use today (whilst other browser vendors implement native support – which might be a while away).

HTML5 dialog element browser support

If this is the first time you’ve heard of the <dialog> element, let me give you a quick overview.

In short, the <dialog> element provides native modal dialog support in the browser. In the past, we’ve had to rely on using JavaScript libraries or create our own solutions; which often lack some nice features that come with the <dialog> element, such as:

  • Focusing the first interactive element when the <dialog> is opened
  • Making all interactive DOM elements outside of the <dialog> not keyboard focusable whilst the <dialog> is open
  • Pressing the ESC key to close the <dialog>

Having used the <dialog> element on a handful of projects, I’ve found a few quirks and improvements that you can also use to create better, more inclusive implementations.

Setting up the markup

The markup needed is pretty simple. There are 3 key elements we need:

  • A <button> used to open the <dialog>
  • The <dialog> itself
  • A <button> used to close the <dialog>

Putting all of these things together, we get this:

<button class="a-button" aria-controls="#js-dialog-1" aria-expanded="false" type="button">Open dialog</button>

<dialog class="o-dialog js-dialog" id="js-dialog-1">
    <div class="o-dialog__wrapper">
        <div class="o-dialog__content">
            <!-- Content will go here -->
        </div>
    </div>

    <button class="o-dialog__close js-dialog-close" aria-label="Close dialog" type="button">
        <svg class="o-dialog__close-icon" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
            <title>Close dialog</title>
            <path d="M3 3l10 10M3 13L13 3"/>
        </svg>
    </button>
</dialog>

Ok, let’s take a look at a few of these things in more detail:

  • aria-controls – the value should match the value of the id attribute on the <dialog> element, this tells screen readers that these elements are linked together (we’ll also be using this to get the open button in our JavaScript)
  • aria-expanded – tells screen readers that this element controls the visibility of another element, in our case, the <dialog> is not visible so the value is set to false
  • aria-label – will be read aloud by screen readers
  • <title> – will display the text when a user hovers over the button, just like the title attribute does

Adding some style

Our CSS is going to be pretty straightforward, but there are a few things in here that you might not have seen before.

So we’re going to break it up into 2 blocks of code:

.o-dialog {
    background-color: #ffffff;
    border: none;
    border-radius: 0.25rem;
    box-shadow: 0 1.25rem 2.5rem 0 rgba(33, 43, 54, 0.25);
    display: block;
    left: 0;
    margin: auto;
    max-height: 90%;
    max-width: 25rem;
    overflow: auto;
    overflow-scrolling: touch;
    padding: 0;
    position: fixed;
    right: 0;
    top: 50%;
    transform: translateY(-50%);
    width: 90%;
}

@media screen and (min-width: 32em) {
    .o-dialog {
        max-width: 31.25rem;
    }
}

@media screen and (min-width: 48em) {
    .o-dialog {
        max-width: 37.5rem;
    }
}

.o-dialog__content {
    padding: 1.25rem;
}

.o-dialog__close {
    background-color: transparent;
    border-radius: 100%;
    color: #212b36;
    padding: 0.625rem;
    position: absolute;
    right: 0.625rem;
    top: 0.625rem;
    transition: background-color 0.3s cubic-bezier(0.5, 0.61, 0.355, 1), box-shadow 0.3s cubic-bezier(0.5, 0.61, 0.355, 1), color 0.3s cubic-bezier(0.5, 0.61, 0.355, 1);
}

.o-dialog__close:focus {
    box-shadow: 0 0 0 0.25rem rgba(33, 43, 54, 0.5);
    outline: none;
}

.o-dialog__close:hover {
    background-color: rgba(33, 43, 54, 0.125);
    color: #212b36;
}

.o-dialog__close-icon {
    display: block;
    height: 1rem;
    stroke: currentColor;
    stroke-width: 1.5;
    width: 1rem;
}

This code is just basic styling for our <dialog> and its related elements, so feel free to tweak this as you see fit.

Now let’s take a look at the more complicated stuff:

.o-dialog::-webkit-backdrop {
    background-color: rgba(33, 43, 54, 0.75);
}

.o-dialog::backdrop {
    background-color: rgba(33, 43, 54, 0.75);
}

.o-dialog:not([open]) {
    display: none;
}

@supports (max-height: -webkit-fill-available) {
    .o-dialog {
        overflow: hidden;
    }
}

@supports (max-height: -webkit-fill-available) {
    .o-dialog__wrapper {
        max-height: -webkit-fill-available;
        max-height: fill-available;
        overflow: auto;
        overflow-scrolling: touch;
    }
}
  • .o-dialog::backdrop – the ::backdrop pseudo-element is the overlay that is displayed when the <dialog> is open
  • .o-dialog:not([open]) – is checking if the <dialog> doesn’t have the open attribute and hiding it
  • @supports (max-height: -webkit-fill-available) – is checking if the browser supports the -webkit-fill-available property and if it does we’re removing overflow: auto from .o-dialog and adding it to the child element. This way if the <dialog> has enough content for the user to scroll through, then the close button will stay fixed at the top of the <dialog> as the user scrolls up/down. If it doesn’t, the close button won’t stay fixed and the user will have to scroll to the top to see it – not a deal breaker by any means.
fill-available property browser support

Hooking up our JavaScript

Now that we’ve styled up our <dialog> it’d be great if we could actually open and close it.

So let’s jump into the JavaScript:

class Dialog {
    constructor(dialog) {
        this.dialog = dialog;
        this.openButton = document.querySelector(`[aria-controls="#${this.dialog.getAttribute('id')}"]`);
        this.closeButton = this.dialog.querySelector('.js-dialog-close');

        this.openDialog = this.openDialog.bind(this);
        this.closeDialogBackdrop = this.closeDialogBackdrop.bind(this);
        this.closeDialog = this.closeDialog.bind(this);

        this.addEventListeners();
    }

    addEventListeners() {
        this.openButton.addEventListener('click', this.openDialog);
        this.closeButton.addEventListener('click', this.closeDialog);
        this.dialog.addEventListener('cancel', this.closeDialog);
        this.dialog.addEventListener('click', this.closeDialogBackdrop);
    }

    openDialog(event) {
        /* If the trigger to open the dialog has an event, prevent the default behaviour */
        if(event) {
            event.preventDefault();
        }

        this.dialog.showModal();

        this.updateOpenButtonState();
    }

    updateOpenButtonState() {
        /* Update button aria-expanded value based on dialog being open/closed */
        if(this.openButton.getAttribute('aria-expanded') === 'false') {
            this.openButton.setAttribute('aria-expanded', true);
        } else {
            this.openButton.setAttribute('aria-expanded', false);
            /* Focus the open button when the dialog is closed */
            this.openButton.focus();
        }
    }

    closeDialogBackdrop(event) {
        const rect = this.dialog.getBoundingClientRect();
        /* Checks if the clicked area is the ::backdrop pseudo-element */
        const inDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height && rect.left <= event.clientX && event.clientX <= rect.left + rect.width);

        /* If the user has clicked the ::backdrop pseudo-element, close the dialog */
        if(!inDialog) {
            this.closeDialog();
        }
    }

    closeDialog(event) {
        /* If the trigger to close the dialog has an event, prevent the default behaviour */
        if(event) {
            event.preventDefault();
        }

        this.dialog.close();
    }
}

/* Find all dialog organisms and instantiate a new instance of the Dialog class */
const dialogs = document.querySelectorAll('.js-dialog');
Array.prototype.forEach.call(dialogs, function(dialog) {
    const dialogEl = new Dialog(dialog);
});

Most of this should hopefully make sense to you, but I do want to pick up on one point:

  • this.dialog.addEventListener('cancel', this.closeDialog); – the cancel event is fired when the user presses the ESC key, we can prevent the default behaviour of the cancel event, which will allow us to animate the closing of the <dialog> later on

Making the dialog shareable

Now that we’ve laid the groundwork for our <dialog>, we can look at enhancing it even further.

One great thing about the web is being able to share content with other people.

The issue with dialogs is, by default, they’re closed unless the user opens it manually.

But this isn’t a great user experience, especially if the user wants to share a link that shows the information that’s inside the <dialog>.

So with a little tweak to the HTML and a bit of JavaScript, we can change this:

<dialog class="o-dialog js-dialog" id="js-dialog-1" data-hash="#dialog">
class Dialog {
    constructor(dialog) {
        ...
        this.hash = this.dialog.getAttribute('data-hash');

        this.openDialogOnPageLoad();
        ...
    }

    addEventListeners() {
        ...
    }

    openDialogOnPageLoad() {
        /* If the page url hash matches the `data-hash` attribute value of the dialog element, show the dialog */
        if(window.location.hash === this.hash) {
            this.openDialog();
        }
    }

    openDialog(event) {
        ...

        /* Add hash value to the URL */
        window.location = this.hash;
    }

    updateOpenButtonState() {
        ...
    }

    closeDialogBackdrop(event) {
        ...
    }

    closeDialog(event) {
        ...

        /* Remove the hash value from the URL, using the History API to prevent page reload */
        history.replaceState({}, document.title, '.');
    }
}

What we’ve done here is:

  1. Added the data-hash attribute to the <dialog> element
  2. Updated the openDialog() and closeDialog() methods to use this value and update the URL when the <dialog> is opened and closed
  3. Created a new method that gets fired on page load, to check if the URL has a hash value that matches the data-hash value set on the <dialog> element and shows it if it does

Animating the dialog element

So now that we’ve got the <dialog> working, let’s look at animating the opening and closing of the <dialog>.

For this, we’ll be using CSS animations as I’ve found transitions don’t work very well.

Let’s start with the opening animation first:

.o-dialog[open] {
    animation: o-dialog-show 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

.o-dialog[open]::backdrop {
    animation: o-dialog-backdrop-show 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

@keyframes o-dialog-show {
    0% {
        opacity: 0;
        transform: translateY(-25%);
    }
    50% {
        opacity: 1;
    }
    100% {
        opacity: 1;
        transform: translateY(-50%);
    }
}

@keyframes o-dialog-backdrop-show {
    0% {
        opacity: 0;
    }
    100% {
        opacity: 1;
    }
}

Nothing too crazy going on here. The only thing worth mentioning is; you need to animate the <dialog> and the ::backdrop independently.

Unfortunately, the closing animation isn’t as easy. Whilst we could create the reverse animation and apply it to .o-dialog and .o-dialog::backdrop, you get an annoying flash of the <dialog> closing when the page is loaded – which is not ideal.

So instead, we need to build on this to only apply the closing animation whilst the <dialog> is closing:

class Dialog {
    constructor(dialog) {
        ...

        this.animateCloseDialog = this.animateCloseDialog.bind(this);
    }

    ...

    closeDialog(event) {
        ...

        /* Begin the close animation */
        this.dialog.classList.add('is-hiding');

        /* Once the close animation has finished, reset dialog state and close */
        this.dialog.addEventListener('animationend', this.animateCloseDialog);
    }

    animateCloseDialog() {
        this.dialog.classList.remove('is-hiding');
        this.dialog.close();
        this.dialog.removeEventListener('animationend', this.animateCloseDialog);
    }
}
.o-dialog.is-hiding {
    animation: o-dialog-hide 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

.o-dialog.is-hiding::backdrop {
    animation: o-dialog-backdrop-hide 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

@keyframes o-dialog-hide {
    0% {
        opacity: 1;
        transform: translateY(-50%);
    }
    50% {
        opacity: 0;
    }
    100% {
        opacity: 0;
        transform: translateY(-25%);
    }
}

@keyframes o-dialog-backdrop-hide {
    0% {
        opacity: 1;
    }
    100% {
        opacity: 0;
    }
}

So what we’ve done is:

  • Removed this.dialog.close(); from the closeDialog() method
  • Added is-hiding class to the <dialog> inside the closeDialog() method
  • Added the animationend event to the <dialog> to check when the animation has finished, which then fires the new animateCloseDialog() method that closes the <dialog> and removes the is-hiding class and animationend event

Removing animations for prefers-reduced-motion users

Now that we’ve gone through all of that effort to animate the opening and closing of the <dialog>, let’s remove it.

If you’re wondering why we’d remove the animation, it’s for users who have reduced motion turned on.

Some people might have this option turned on because they find animations annoying, whilst others might have it enabled as they suffer from vestibular disorders.

Whatever the reason, whether the animations are simple or complex, it’s better to remove them all together.

For this, we can use the prefers-reduced-motion media query (currently only support in Safari).

prefers-reduced-motion media query browser support

Thankfully, removing animations is quite simple and won’t take long to update:

@media screen and (prefers-reduced-motion: reduce) {
    * {
        animation: none!important;
        transition: none!important;
    }
}
closeDialog(event) {
    ...

    /* If the users browser supports reduced motion and is enabled, close the dialog, otherwise run close animation */
    if(matchMedia('(prefers-reduced-motion)').matches) {
        this.dialog.close();
    } else {
        /* Begin the close animation */
        this.dialog.classList.add('is-hiding');

        /* Once the close animation has finished, reset dialog state and close */
        this.dialog.addEventListener('animationend', this.animateCloseDialog);
    }
}

We’re simply removing animations and transitions from all elements and updated the closeDialog() method to close the <dialog> if prefers-reduced-motion is supported and enabled.

If you want to apply these same principles to autoplaying videos, check out my post: autoplay HTML5 video on iOS, android and desktop.

Mac: how to turn on reduced motion

If you have a mac you can turn on reduced motion by following these steps:

  1. Open System Preferences
  2. Click on Accessibility
  3. Click on Display
  4. Check the Reduce Motion checkbox

iOS: how to turn on reduced motion

If you have an iOS device (iPhone/iPad), you can turn on reduced motion by following these steps:

  1. Open Settings
  2. Tap on General
  3. Tap on Accessibility
  4. Tap on Reduce Motion
  5. Toggle on Reduce Motion

Polyfilling the dialog element

We’ve done some great work up until this point, but our <dialog> will only work in Google Chrome. So let’s take a look at adding in Googles polyfill for other browsers.

Once you’ve added the polyfill (I’ll be doing it through NPM and ES6 imports), we need to make a few minor updates to our JavaScript and CSS:

import dialogPolyfill from 'dialog-polyfill';

class Dialog {
    constructor(dialog) {
        ...

        this.polyfillDialog();
    }

    ...

    polyfillDialog() {
        dialogPolyfill.registerDialog(this.dialog);
    }
}
.o-dialog + .backdrop {
    background-color: rgba(33, 43, 54, 0.75);
}

.o-dialog + .backdrop,
._dialog_overlay {
    bottom: 0;
    left: 0;
    position: fixed;
    right: 0;
    top: 0;
}

.o-dialog[open] + .backdrop {
    animation: o-dialog-backdrop-show 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

.o-dialog.is-hiding + .backdrop {
    animation: o-dialog-backdrop-hide 0.45s cubic-bezier(0.5, 0.61, 0.355, 1) normal;
}

Fortunately, all we need to do is instantiate the polyfill and add some CSS for the new element created as a replacement for the ::backdrop pseudo-element.

Tip: If you’re having trouble horizontally centring the <dialog> in Safari, make sure you have left: 0; right: 0; set on the <dialog> in the CSS.

Browser quirks

So far I’ve only come across 1 browser quirk.

In Google Chrome, if you have a form with checkboxes and you press the space key whilst focusing the checkbox, it checks/un-checks the checkbox and closes the dialog.

Whether this is a bug with the <dialog> element or a bug in my code, I’m not sure yet – but I will update this section of the post when I find out.

Fin

Hopefully, you’ve learnt something new from this post and we see other browsers implement the <dialog> element, but until then I hope I’ve prepared you with the information in this post.

If you’ve enjoyed this post I’d really appreciate it if you shared it around online .

Equally, if you have any questions don’t hesitate to drop me a message on social media .

Thank you, your answers have been sent
These will help me create more relevant content that you’re looking for.
Which topics would you like to see more of?
Thank you, your answers have been sent
These will help me create more relevant content that you’re looking for.
Always stay up to date
What would you like to recieve?
Please confirm that you’d like to hear from me

I’ll only use the information you provide on this form to get touch about the topics you’ve chosen above. For more information on how I use and protect your data, please view my privacy policy.

Related tutorials