I had to make a modal recently, it happened to be in React, so some of what follows is React specific, but the principles are all good ol' CSS/HTML and JavaScript, so don't necassarily go running just because I'm using React here…. wait, where are you going? Come back…

Oh well, just you left I guess. Nevermind, let's make a decent modal with the <dialog> element in React.

The basics of the dialog element

I've covered using <dialog> elements with standard HTML, CSS, and JavaScript in my book, Responsive Web Design with HTML5 and CSS but for React, things are just slightly different. To quote myself from that title (because I'm clearly that conceited), here is the briefest of primers on the <dialog> element.

When the modal dialog is opened, it automatically gets centered and focused, and the backdrop of the dialog — which is automatically inserted — covers everything below, preventing any of the underlying content from being interacted with. You can dismiss the dialog with the button inside it, or a keyboard press of the Esc key; the keyboard support is functionality you get for free!

That's quite a bit of functionality provided 'free' by the web platform. We will build on that.

Requirments for the modal

If you have ever tried to implement a fully working modal without the <dialog> element in the past, you will appreciate how tricky some of the accessibility features are to get right. As such, and with very good browser support nowadays, the dialog is our starting point.

Here is the list of requirements I gave myself for the modal.

  • Can be dismissed with Escape on Keyboard
  • Transitions in
  • Transitions out
  • Backdrop also transitions in/out
  • Actually works on Safari
  • Can be dismissed with a button inside
  • Can be dismissed by clicking the backdrop
  • Can put any content inside we want

What you will end up with (demo)

I'm using Codesandbox editor here to demo the modal.

Here is what you should have at the end. Click to open the modal, and to dismiss, press escape, click the backdrop, or cross. Most importantly, marvel at the fact that the dialog transitions in and out, and actually does all the same things in Safari (no mean feat!).

Some of the requirements were handled 'out of the box' by <dialog>, such as using Escape to dismiss. Everything else took a little tinkering, so I'll highlight each below. I'll cover functional stuff first, and then onto the transitions.

Dismissed by clicking a button inside

The specification shows using a <form> element inside for the content. That also then lets you easily return the value of the button when the form is submitted (you will need to add the method="dialog" attribute to the dialog too). However, I was feeling contrarian so opted to just use an inner <div>. The use of this inner element is actually important, and we will get to why shortly.

But in terms of dismissing the <dialog> I have a button:

<button
    ref={buttonRef}
    className={styles.close}
    onClick={(e) => closeDialog(e)}
>
    <span className={styles.cross}></span>
</button>

There is a ref on there and the click handler passes the event to my closeDialog method. This is important because we need to know if the thing being clicked is actually the button, the background, or the content. If it is the content, we don't want to dismiss the modal, otherwise we do.

Here is the closeDialog, and you can see that only if the thing is the close button (buttonRef), or the <dialog> itself, we close the <dialog>. The content is wrapped in an inner <div>, so doesn't activate the closeDialog.

function closeDialog(e) {
    if (e.target === buttonRef.current || e.target === dialogRef.current) {
        dialogRef.current?.close();
    }
}

Dismiss by clicking the backdrop

In Chrome/Edge etc, if you want to be able to dismiss the <dialog> element by clicking the backdrop, it's as simple as adding the closedby="any" attribute to the dialog element.

Safari however, still doesn't have this – the useless POS – so you'll need to add more code and elements to deal with it.

Thanks Safari.

The crux of being able to workaround this is that the ::backdrop pseudo element, is actually part of the <dialog> element, so by wrapping the content in our aformentioned element, then clicks from your actual content can be distinguished from clicks on the backdrop. You have seen the closeDialog method above that handles that already – because when you are clicking the backdrop, you are effectively clicking the dialog, and not the content, we dismiss it.

Adding styling options

There are a bunch of optional props that can be passed in my implementation to control the aesthetics; border radii, color, speed of transitions etc – you may want more of less of that stuff but many of these get exposed in the CSS as Custom Properties:

const styleProps = {
    "--padding": `${padding}px`,
    "--radii": `${radii}px`,
    "--height": `${height}`,
    "--width": `${width}`,
    "--transitionSpeed": `${transitionSpeed}`,
} as React.CSSProperties;

These Custom Properties are all applied to the structure of the modal, on the <dialog> element.

The content of the modal

In terms of what is shown inside the modal, I use a content prop and just send in whatever component I want to see in there. In my demo, the content sent in is simple as:

const ModalTestInner = () => <div>Here is the content</div>;

Transitioning the <dialog> in and out

For transitioning the appearance of the <dialog> in and out, I'm leaning on modern CSS. In my mind, this part of the requirements is the domain of 'progressive enhancement'.

To transition the modal in, I'm using CSS's @starting-style which allows you to define in CSS what visual state the element should be in BEFORE it is added to the DOM. This means we can add a transition to the element, and when it is added to the DOM it will transition from its starting-style, to the default style.

When you add @starting-style you always want to add it after any other equivalent styles as it does not affect specificity. It stands to reason therefore that if it is not after other styles, those other styles will superceded the prior ones and you won't get the desired effect.

Because I wanted @starting-styles for both the <dialog> and its ::backdrop, rather than nesting them in the normal rules for the element, as I usually do, I actually split them out below the other rules.

@starting-style {
    .dialog[open],
    .dialog:not([open]) {
        transform: scale(0.8);
        opacity: 0;
    }
    .dialog[open]::backdrop {
        opacity: 0;
    }
}

But look, defining the starting styles is acually the easy part. I think the amount of properties and values I have had to add in CSS alongside these to get this actually working correctly (cough Safari cough), and the order in which they need to be added is a little bit like alchemy. It's one of the main reasons I wrote this post, so that I would have a reference for my future self (wait, you think I did this for you? Mwah ha ha!).

Principal in terms of difficulty, is getting the modal to transition when dismissed. If there is a 'secret sauce' to remember, it is adding allow-discrete to your transitions. Here I am transitioning 'all' properties, but if you are transitioning different properties at different speeds/easings, ensure that display and overlay get allow-discrete added. That enables the browser to transition those two properties, which are not ordinarily 'transitionable'.

Transitioning the ::backdrop element

By default, even if you add a transition to the dialog element, the ::backdrop does not transition with it. To get that transitioning too, it needs its own transition, complete with allow-discrete adding, and the various open states adding too. Check those out in the code.

Go home Safari, you're drunk

No write-up seems to be complete without a little special attention to get Safari to behave predictably. In this example, for reasons known only to those in Cupertino, when you dismiss a <dialog>, it's inset values get changed from whatever the defaults are. This led to the dialog jumping down the page when dismissed.

To remedy that, we need to add a distinct inset: 0 and position: fixed declaration in the dialog. An easy fix, but one that took a good few minutes to figure out.

Summary

It felt like more work than it should be, CSS wise, to get the transitions working, including the ::backdrop, as I desired. This approach also lets you have alternative entry and exit positions/transitions so play about to get your desirsed effect.

Hopefully, this gives you the basis for an accessible modal in React that still enjoys the visual flourish you would like and some flexibility to restyle as needed.