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
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>
);
}
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>
);
}
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
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>
);
}
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]);
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>
);
}
Two important aspects to note here:
- We're using the
twMerge
function from thetailwind-merge
package to combine the default styles with those specified by the user. - 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]);
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>
This approach will help in creating a smooth, visually appealing transition for your modal.
Top comments (1)
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