React: Using native dialogs to make a modal popup

Andrew Bone - May 19 '22 - - Dev Community

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.

can I use for dialog

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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]
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 😊.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .