Skip to content

Converting Our Modal

Video Summary

In this lesson, we migrate an application that uses our home-grown Modal component to use the Dialog component from the Headless UI library.

This application includes a Header component that consumes the modal, like so:

function Header() {
const [isModalOpen, toggleIsModalOpen] = useToggle(false);
return (
<header>
{isModalOpen && (
<Modal
title="Log in"
handleDismiss={() => toggleIsModalOpen(false)}
>
<LoginForm />
</Modal>
)}
<button onClick={toggleIsModalOpen}>
Log in
</button>
</header>
);
}

This sandbox comes with the Modal component we built in a previous lesson. Our goal is to update this component to use the Dialog component from Headless UI.

We can grab an example implementation from the docs, to use as a reference:

import { useState } from 'react'
import { Dialog } from '@headlessui/react'
function MyDialog() {
let [isOpen, setIsOpen] = useState(true)
return (
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<Dialog.Panel>
<Dialog.Title>Deactivate account</Dialog.Title>
<Dialog.Description>
This will permanently deactivate your account
</Dialog.Description>
<p>
Are you sure you want to deactivate your account? All of your data
will be permanently removed. This action cannot be undone.
</p>
<button onClick={() => setIsOpen(false)}>Deactivate</button>
<button onClick={() => setIsOpen(false)}>Cancel</button>
</Dialog.Panel>
</Dialog>
)
}

Dialog is the wrapper component, Dialog.Panel is the box itself that includes content. Dialog.Title is for the modal's title, and Dialog.Description holds an optional description. These components use the Compound Components pattern we learned about earlier.

The Dialog component accepts an open prop, and this is something our Modal doesn't currently support. We've been using conditional rendering to show/hide the modal, but it's common for third-party Modal components to have a prop that controls this.

We could solve this by locking the open prop to true. If these elements are being rendered, we know the modal must be open! But I feel like this just looks strange / requires too much explanation:

<Dialog open={true}>

Instead, we'll add an isOpen prop to our Modal component.

Inside Modal, we'll use these various Dialog elements, fitting them in with our component. We're left with this:

function Modal({
title,
description,
isOpen,
handleDismiss,
children,
}) {
return (
<Dialog open={isOpen} onClose={handleDismiss}>
<Dialog.Panel>
<Dialog.Title>{title}</Dialog.Title>
{description && (
<Dialog.Description>
{description}
</Dialog.Description>
)}
{children}
</Dialog.Panel>
</Dialog>
);
}

If we test this out, we'll see that clicking the "Log in" button does add a login form to the page, but it doesn't appear to be in a modal. It's not floating above the content, it's sitting underneath!

This is why Headless UI is considered an “unstyled” component library. It doesn't come with any CSS, out of the box! Not even critical styles, like setting position: fixed on the modal to float it above the content.

We can fix this by adding the CSS classes we were already using:

function Modal({
title,
description,
isOpen,
handleDismiss,
children,
}) {
return (
<Dialog
className={styles.wrapper}
open={isOpen}
onClose={handleDismiss}
>
<div
className={styles.backdrop}
onClick={handleDismiss}
/>
<Dialog.Panel className={styles.dialog}>
<Dialog.Title>{title}</Dialog.Title>
{description && (
<Dialog.Description>
{description}
</Dialog.Description>
)}
{children}
</Dialog.Panel>
</Dialog>
);
}

We've added classes to Dialog and Dialog.Panel. Also, we had to bring over the “backdrop” element, since HeadlessUI's dialog doesn't come with one by default.

Finally, we need to add our close button:

<button
className={styles.closeBtn}
onClick={handleDismiss}
>
<Close />
<VisuallyHidden>Dismiss modal</VisuallyHidden>
</button>

With that, our modal migration is complete!

Why do we still have a Modal component? Can't we use the Dialog that we get from Headless UI directly, whenever we need a modal (eg. in Header)?

Well, we can, but it would be an awful lot of work. We've customized the modal quite a bit, with our custom styles, backdrop, and close button.

There are two more reasons that it's worthwhile to have our own wrappers around third-party library components:

  1. We can choose a consistent prop API. In a larger app, we might use components from 7 different libraries and packages, and they all have their own names for common props like isOpen and handleDismiss. If we create our own wrappers, we have total control over the interface, and we can make sure it's consistent.
  2. We might decide, later, to migrate to a different third-party component. This way, we only have to change 1 file. We don't have to make a bunch of changes to every single component that has a modal.

Here's the original code from the video, in case you wanted to practice migrating the modal:

Code Playground

import React from 'react';
import { X as Close } from 'react-feather';
import FocusLock from 'react-focus-lock';
import { RemoveScroll } from 'react-remove-scroll';

import VisuallyHidden from './VisuallyHidden'
import styles from './Modal.module.css';

function Modal({ title, handleDismiss, children }) {
React.useEffect(() => {
function handleKeyDown(event) {
if (event.code === 'Escape') {
handleDismiss();
}
}

window.addEventListener('keydown', handleKeyDown);

return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleDismiss]);
return (
<FocusLock returnFocus={true}>
<RemoveScroll>
<div className={styles.wrapper}>
<div
className={styles.backdrop}
onClick={handleDismiss}
/>
<div
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button
className={styles.closeBtn}
onClick={handleDismiss}
>
<Close />
<VisuallyHidden>
Dismiss modal
</VisuallyHidden>
</button>

<h2>{title}</h2>

{children}
</div>
</div>
</RemoveScroll>
</FocusLock>
);
}

export default Modal;

…and here's the converted code, using the Headless UI library:

Code Playground

import React from 'react';
import { X as Close } from 'react-feather';
import { Dialog } from '@headlessui/react';

import VisuallyHidden from './VisuallyHidden';
import styles from './Modal.module.css';

function Modal({
title,
description,
isOpen,
handleDismiss,
children,
}) {
return (
<Dialog
className={styles.wrapper}
open={isOpen}
onClose={handleDismiss}
>
<div
className={styles.backdrop}
onClick={handleDismiss}
/>
<Dialog.Panel className={styles.dialog}>
<button
className={styles.closeBtn}
onClick={handleDismiss}
>
<Close />
<VisuallyHidden>Dismiss modal</VisuallyHidden>
</button>

<Dialog.Title>{title}</Dialog.Title>
{description && (
<Dialog.Description>
{description}
</Dialog.Description>
)}
{children}
</Dialog.Panel>
</Dialog>
);
}

export default Modal;