Taming the HTML dialog with React and TailwindCSS
Tags: html, javascript, a11y, react
Published: 2023-11-01 18:26:14

![Preview GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gwfxuh504ebttkv6bwl3.gif)

Today, we're going to create a modal component using HTML's native `dialog` element, along with React and TailwindCSS.

To see it in action, visit this [link](https://html-dialog-react-tailwind.fibonacid.net/).

If you'd like to try out my implementation, you can find it in the GitHub [Repository](https://github.com/fibonacid/html-dialog-react-tailwind) or download the component using this command:

```bash
npx degit \
   fibonacid/html-dialog-react-tailwind/src/Step3.tsx \
   ./Modal.tsx
```

Make sure you have TailwindCSS configured and `tailwind-merge` installed as a runtime dependency in your project. Let's get started!

---

## Step 1: Wrap the HTML Dialog Element

Working with the HTML dialog element can be a bit tricky. It's a good practice to create a wrapper for it, providing a declarative API for ease of use. Let's begin by simply wrapping the element and passing all its properties through:

```tsx
// components/Modal.tsx
import { type ComponentPropsWithoutRef } from "react";

export type ModalProps = ComponentPropsWithoutRef<"dialog">;

export default function Modal(props: ModalProps) {
  return (
    <dialog {...rest}>
      {children}
    </dialog>
  );
}
```

In the HTML dialog element's [documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog), you'll find it has an open attribute. Naturally, you might think to use this with React to control the modal's visibility. However, it's not that straightforward. Using the following code reveals that once opened, the modal can't be closed:

```tsx
import { useState } from "react";
import Modal from "./components/Modal";

export default function App() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button
        className="m-4 underline outline-none focus-visible:ring"
        onClick={() => setOpen(true)}
        aria-controls="modal"
        aria-labelledby="modal-title"
        aria-describedby="modal-desc"
      >
        Open modal
      </button>
      <Modal id="modal" open={open} onClose={() => setOpen(false)}>
        <h2 id="modal-title" className="mb-1 text-lg font-bold">
          Modal
        </h2>
        <p id="modal-desc">
          Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ab optio
          totam nihil eos, dolor aut maiores, voluptatum reprehenderit sit
          incidunt culpa? Voluptatum corrupti blanditiis nihil voluptatem atque,
          dolor ducimus! Beatae.
        </p>
        <button
          autoFocus={true}
          className="float-right underline outline-none focus-visible:ring"
          onClick={() => setOpen(false)}
          aria-label="Close modal"
        >
          Close
        </button>
      </Modal>
    </div>
  );
}
```

The open attribute is intended for reading the dialog's state, not setting it. To open and close the dialog, we must use the [`showModal`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal) and [`close`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close) methods:

```tsx
const dialog = document.querySelector('dialog');

dialog.showModal();
console.log(dialog.open); // true

dialog.close();
console.log(dialog.open); // false 
```

To synchronize the dialog's state with React, we'll use a useEffect hook. It will listen for changes to the open prop and call the `showModal` and `close` methods accordingly:

```tsx
// components/Modal.tsx
import { useEffect, useRef, type ComponentPropsWithoutRef } from "react";

export type ModalProps = ComponentPropsWithoutRef<"dialog"> & {
  onClose: () => void;
};

export default function Modal(props: ModalProps) {
  const { children, open, onClose, ...rest } = props;
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = ref.current!;
    if (open) {
      dialog.showModal();
    } else {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog ref={ref} {...rest}>
      {children}
    </dialog>
  );
}
```

If you rerun the code in `App.tsx`, the modal now behaves as expected. However, there's still an issue. If you open the modal and then press <ESC>, it closes, but the React state isn't updated. Consequently, clicking the open button again won't re-trigger the effect, and the modal won't open. To address this, we need to listen to the dialog's close and cancel events, updating the state accordingly:

```tsx
useEffect(() => {
  const dialog = ref.current!;
  const handler = (e: Event) => {
    e.preventDefault();
    onClose();
  };
  dialog.addEventListener("close", handler);
  dialog.addEventListener("cancel", handler);
  return () => {
    dialog.removeEventListener("close", handler);
    dialog.removeEventListener("cancel", handler);
  };
}, [onClose]);
```

With these changes, our modal component should now be fully functional.

---

## Step 2: Style the Modal

Now that our dialog is functional, let's enhance its appearance with TailwindCSS. We'll modify the Modal component to apply a default set of styles, which can be extended by users through the className property:

```tsx
// same imports...
import { twMerge } from "tailwind-merge";

// same type...

export default function Modal(props: ModalProps) {
  const { children, open, onClose, className, ...rest } = props;
  
 // same hooks...

  return (
    <dialog ref={ref} className={twMerge("group", className)} {...rest}>
      <div className="fixed inset-0 grid place-content-center bg-black/75">
        <div className="w-full max-w-lg bg-white p-4 shadow-lg">{children}</div>
      </div>
    </dialog>
  );
}
```

Two important aspects to note here:

1. We're using the `twMerge` function from the [`tailwind-merge`](https://www.npmjs.com/package/tailwind-merge) package to combine the default styles with those specified by the user.
2. We are using divs to render the backdrop and the modal container instead of using the `::backdrop` pseudo-element. This is because styling the dialog element itself is tricky, especially when it comes to CSS Transitions.

---

## Step 3: Animate the Modal

Animating a modal component using the HTML `<dialog>` element can be somewhat complex. A key challenge arises because when this element is closed, browsers automatically apply a `display: none` style. This style interferes with the smooth application of CSS transitions and animations.

To work around this, we need to manage the timing of applying the `open` property. It's important to delay its application until the enter/exit transitions are complete. For this, we'll use a `data-open` attribute. This attribute allows us to toggle between open and closed states without activating the `display: none` style.

Here's how we can update the `useEffect` hook to handle this:

```tsx
useEffect(() => {
  const dialog = ref.current!;
  if (open) {
    dialog.showModal();
    dialog.dataset.open = "";
  } else {
    delete dialog.dataset.open;
    const handler = () => dialog.close();
    const inner = dialog.children[0] as HTMLElement;
    inner.addEventListener("transitionend", handler);
    return () => inner.removeEventListener("transitionend", handler);
  }
}, [open]);
```

For applying transitions, we can use a TailwindCSS class named `group`. This class enables us to apply conditional styles to the children of our `dialog` component effectively. Here’s how to integrate it:

```tsx
<dialog ref={ref} className={twMerge("group", className)} {...rest}>
  <div className="fixed inset-0 grid place-content-center bg-black/75 opacity-0 transition-all group-data-[open]:opacity-100">
    <div className="w-full max-w-lg scale-75 bg-white p-4 opacity-0 shadow-lg transition-all group-data-[open]:scale-100 group-data-[open]:opacity-100">
      {children}
    </div>
  </div>
</dialog>
```

This approach will help in creating a smooth, visually appealing transition for your modal.