DEV Community

Cover image for Taming the HTML dialog with React and TailwindCSS
Lorenzo Rivosecchi
Lorenzo Rivosecchi

Posted on

Taming the HTML dialog with React and TailwindCSS

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

If you'd like to try out my implementation, you can find it in the GitHub Repository or download the component using this command:

npx degit \
   fibonacid/html-dialog-react-tailwind/src/Step3.tsx \
   ./Modal.tsx
Enter fullscreen mode Exit fullscreen mode

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:

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

export type ModalProps = ComponentPropsWithoutRef<"dialog">;

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

In the HTML dialog element's documentation, 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:

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

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 and close methods:

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

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

dialog.close();
console.log(dialog.open); // false 
Enter fullscreen mode Exit fullscreen mode

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:

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

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 , 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:

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

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:

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

Two important aspects to note here:

  1. We're using the twMerge function from the 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:

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

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:

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

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

Top comments (1)

Collapse
 
fibonacid profile image
Lorenzo Rivosecchi

I wrote another article about HTML dialogs
dev.to/fibonacid/creating-a-todo-a...

This new one handles the interactivity aspect a little bit better