๐ Taming the HTML dialog with React and TailwindCSS
๐ก Newskategorie: Programmierung
๐ Quelle: dev.to
Today we are going to build a modal component using the native HTML <dialog>
element with React and TailwindCSS.
Step 1: Wrap the HTML dialog element
The HTML dialog is a bit tricky to work with, therefore is a good practice to create a wrapper that will give us a declarative API to work with.
Let's start by simply wrapping the element and passing all the props to it:
// Modal.tsx
import { ComponentPropsWithoutRef } from 'react';
export type ModalProps = ComponentPropsWithoutRef<'dialog'>;
export function Modal(props: ModalProps) {
const { children, open, ...rest } = props;
return (
<dialog {...rest}>
{children}
</dialog>
)
}
If you read the documentation of the HTML dialog element you will notice that it has a open
attribute. The natural thing would be to use it with React to toggle the visibility of the modal, but it's not that simple.
If you try the following code you will see that once opened the modal can't be closed:
// App.tsx
import { useState } from "react";
import { Modal } from "./Modal";
export function App() {
const [open, setOpen] = useState(false);
return (
<header>
<button onClick={() => setOpen(true)}>Open<button>
<Modal open={open}>
<button onClick={() => setOpen(false)}>Close</button>
</Modal>
</header>
)
}
This is because the open
attribute is supposed to be used to read the state of the dialog, not to set it. To open and close the dialog we need to 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
Step 2: Make the dialog play nice with React
To sync the state of the dialog with React we can use a useEffect
hook that will listen to the changes of the open
prop and call the showModal
and close
methods accordingly.
// Modal.tsx
import { useEffect, type ComponentPropsWithoutRef } from 'react';
export type ModalProps = ComponentPropsWithoutRef<'dialog'>;
export function Modal(props: ModalProps) {
const { children, open, ...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 try to run again the code from App.tsx
, you will see that the modal behaves correctly now. There is still an issue though. If you open the modal and then press <ESC>
the modal will close, but the React state will not be updated. As a result, if you click the open button again, the effect won't re-run and the modal won't open. To fix this we need to listen to the close
and cancel
events of the dialog and update the state accordingly.
// Modal.tsx
import { useEffect, type ComponentPropsWithoutRef } from 'react';
export type ModalProps = ComponentPropsWithoutRef<'dialog'> & {
setOpen: () => void;
};
export function Modal(props: ModalProps) {
const { children, open, setOpen, ...rest } = props;
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current!;
if (open) {
dialog.showModal();
} else {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = ref.current!;
const handler = (e: Event) => {
e.preventDefault();
setOpen(false);
};
dialog.addEventListener("close", handler);
dialog.addEventListener("cancel", handler);
return () => {
dialog.removeEventListener("close", handler);
dialog.removeEventListener("cancel", handler);
};
}, [setOpen]);
return (
<dialog ref={ref} {...rest}>
{children}
</dialog>
)
}
With this change now we should have a fully functional modal component.
Let's now use it to build a search modal.
Step 3: Style the modal
Now that the dialog is functional, let's make it pretty with TailwindCSS.
We are going to modify the Modal
component to provide a default set of styles that can be extended by the consumers using the className
prop.
// import ...
import { twMerge } from 'tailwind-merge';
// export ...
export function Modal(props: ModalProps) {
const { children, open, setOpen, className, ...rest } = props;
const ref = useRef<HTMLDialogElement>(null);
// useEffect ...
// useEffect ...
return (
<dialog
ref={ref}
{...rest}
className={twMerge(
"[&::backdrop]:bg-black/75 bg-white w-full max-w-lg p-4 shadow-lg",
className
)}
>
{children}
</dialog>
)
}
Two things to notice here:
- We are using the
twMerge
function from thetailwind-merge
package to merge the default styles with the ones provided by the consumer. - We are using the
&::backdrop
pseudo-element to style the backdrop of the modal. This is a CSS only solution that works in all the browsers that support the HTML dialog element.
Step 4: Animate the modal (optional)
Animating the modal is a bit tricky because when the dialog is closed, the element is given an implicit display: none
style. This means that we can't use CSS transitions to animate the modal.
To solve this problem we can start by moving the backdrop and container of the modal inside the dialog element using divs.
// ...
export function Modal(props: ModalProps) {
// ...
return (
<dialog className={twMerge("group", className)}>
<div className="bg-black/75 fixed inset-0 grid place-content-center group-open:opacity-100 opacity-0 transition-all">
<div className="bg-white w-full max-w-lg p-4 shadow-lg group-open:scale-100 group-open:opacity-100 opacity-0 scale-75 transition-all">
{children}
</div>
</div>
</dialog>
)
}
Now we can use the group
and group-open
classes to animate the backdrop and the container of the modal.
There is still one issue though. When the modal is closed, the display: none
will hide our exit animation.
To solve this we need to modify our code to make sure that the modal is always visible between the opening and closing animations.
Instead of binding the transition to the open
attribute of the dialog, we can use a custom data attribute called data-open
. By using a separate attribute we can run the entry and exit animations before the dialog state is updated.
In the useEffect hook we set the custom attribute right away, and then we remove it when the dialog is closed. This way we can use the transitionend
event to detect when the exit animation is finished and then close the dialog.
// ...
useEffect(() => {
const dialog = ref.current!;
if (open) {
dialog.showModal();
dialog.dataset.open = "";
} else {
delete dialog.dataset.open;
const handler = () => dialog.close();
dialog.addEventListener("transitionend", handler, { once: true });
return () => dialog.removeEventListener("transitionend", handler);
}
}, [open]);
// ...
In the HTML we can now change the classes to use group-data-[open]
instead of group-open
.
...Note, for the exit animation to work is important to always close it by setting the
open
prop tofalse
. If you close the dialog using a form with themethod="dialog"
attribute, the dialog will be closed without triggering the exit animation.