Around two years ago I wrote a post, that I'm still very proud of, called "Using portals to make a modal popup". It made use of portals and inert. At the time there wasn't really a good way to display a modal popup on top of everything else and trap focus within it.
Two years have passed since the original post and, whilst I would argue the code is still valid and the techniques used are still worth knowing, there is now a better way to code a modal. The dialog element has been around since 2014ish but it has only recently reached full browser compatibility (excusing IE).
Let's look at how we can use the dialog element to make this.
The Dialog Element
What is the Dialog Element? The <dialog>
element is a native html element made with the sole purpose of being popover content. By default the contents of the element are hidden but by setting the open
attribute to true or by calling either of its show methods show()
or showModal()
they can be revealed. The element also has a close
method to hide the modal away again once it's been used.
It is not, however, best practice to set the open
attribute directly, even though it is possible, but rather calling a show method is preferred. show()
makes the dialog appear but leaves the rest of the page interactable this is great for making toast notifications. showModal()
opens the dialog in the centre of the screen and makes all other content inaccessible.
What are the advantages of using it? I'm sure there are numerous reasons to use dialog over making your own solution but I'll focus on three.
- Native elements do not require large libraries or imports, speeding up your app.
- Accessibility is handled for you, when a browser ships an element it is built to a11y standards.
- The logic is pretty much there, you don't have to work out how to make the interactions happen they just work.
Are there any drawbacks? Yes. Well, sort of. The element does not have animations built in and uses display: none
which is famously hard to animate from.
This issue almost feel like deal breakers but there are ways around it, which I will show as we go on.
The general component
I'm not going to spend too long going through the code I've written, feel free to read it and ask questions in the comments, but I'll give a quick run down here and then explain my solutions to issues I mentioned above.
First of all I'm using Sass and CSS Modules if you've seen some of my earlier posts you'll have seen I used to use styled-components and whilst I think they have their place I'm much happier using CSS Modules.
The code does a few things, it has references to use in event listeners (I'll go more into them in the capturing events section), applies classes from the modules as they're required, triggers the showModal()
method when open is set and draws the html (jsx
really) to the screen. That's it.
Adding animations
If our modal just appeared when summoned and disappeared when dismissed that would be ok but it would lack the feeling of polish. In my example you'll have noticed there is a slight fade in and move up effect. Let's look and how we did it.
We have two keyframe animations one called show and one called hide. They simply have a start position and an end position.
@keyframes show{
from {
opacity: 0;
transform: translateY(min(100px, 5vh));
}
to {
opacity: 1;
transform: translateY(0%);
}
}
@keyframes hide{
from {
opacity: 1;
transform: translateY(0%);
}
to {
opacity: 0;
transform: translateY(min(100px, 5vh));
}
}
To apply animations we're going to have to know if the modal is opening or closing, this is where our setting of classnames comes in. We will always apply the modal class but we will only apply the closing class when the modal is not open.
// work out which classes should be applied to the dialog element
const dialogClasses = useMemo(() => {
const _arr = [styles["modal"]];
if (!open) _arr.push(styles["modal--closing"]);
return _arr.join(" ");
}, [open]);
Because the modal isn't closed when we remove the open attribute we can assume the modal is [open]
but has the closing class.
We use this to apply the show animation when the modal is open and hide animation when the modal is open but has the closing class. We also use forwards
as our animation direction so when the animation ends we stay on the last frame.
&[open] {
animation: show 250ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
&.modal--closing {
animation: hide 150ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
}
You may have noticed that with these changes our modal animates away but does not actually close, if you did, well done you're right and very astute. The next section will show you how we can use the animation to truly close the modal.
Capturing events
We have three things to look at here. Two of which are adding events and third is also adding an event but with a little trickery to allow us to close the modal with js, even though there isn't a method for it.
Closing on backdrop click
We can add a click event to the dialog element but there isn't a way to distinguish between clicking on the backdrop and clicking on the modal. The easiest way around this is to put a container inside the modal and have it take up the entire modal. Now when we click inside the modal the target will be the container and when we click outside the modal the target will be the dialog.
// Eventlistener: trigger onclose when click outside
const onClick = useCallback(
({ target }) => {
const { current: el } = modalRef;
if (target === el && !locked) onClose();
},
[locked, onClose]
);
Animating away on Escape
By default pressing escape closes the dialog, this is what we want to happen but unfortunately our animation would go with it so instead let's capture the escape press and deal with it ourselves.
// Eventlistener: trigger onclose when cancel detected
const onCancel = useCallback(
(e) => {
e.preventDefault();
if (!locked) onClose();
},
[locked, onClose]
);
Closing with JS
Both of the event listeners we've implemented so far call the onClose function which, as we discussed earlier, doesn't close the modal it just animates it away. In order to turn this animation into a method for closing we're going to need to add another event listener but this time listening for the closing animation to end.
// Eventlistener: trigger close click on anim end
const onAnimEnd = useCallback(() => {
const { current: el } = modalRef;
if (!open) el.close();
}, [open]);
Closing thoughts
The web is changing and evolving everyday and it's exciting to keep up with what's going on. Thank you for reading and I hope you enjoyed learning about the dialog element and how to use it in React. If you have any questions, please feel free to ask them in the comments I'll be more than happy to answer them.
As I said at the top I really am proud of my original post about this topic so if you haven't read it and you're interested please head over there and have a look.
If you'd like to connect with me outside of Dev here are my twitter and linkedin come say hi 😊.
Top comments (6)
Any reason you don't just use the
close()
method to close the dialog?developer.mozilla.org/en-US/docs/W...
Fwiw, if you want to support older browsers through the dialog-polyfill, you'll still need a portal. We've been using this for a couple years already, works like a charm (I'll have to lookup how exactly we open/close the dialog, can't remember)
The documentation I read on mdn didn't mention the close method so I, foolishly, just accepted there wasn't one. The documentation you linked is a lot more thorough thank you. I've updated my post.
Yes if you support anything older than cutting edge you're going to need either a polyfill or to keep using the old method for now (which is why I linked my old post too).
Fwiw, here's our 3 year old component (at the time, only Chrome supported the element natively):
It was a coincidence that I published this on Global Accessibility Awareness Day but it's very fitting as using platform elements is always more accessible!
Really like this
This was really helpful thanks! The only thing I'm running into issues with is how to type the event with typescript for the click outside function. Any ideas?