Drawer Component With Headless-UI

Read time: 15 min.

Tweet this article
Wooden Drawers

Beauty is an expression

In this tutorial I am going to show you exactly how to build a drawer component using headless-ui. We will utilize the modal component to build the drawer.


Installing Dependencies

You will need to have headless-ui and tailwindcss installed to build the component. I have listed the commands below. For further instructions check out the official install pages headless-ui modal, tailwindcss.

install headless-ui
$> npm install @headlessui/react
install tailwindcss
$> npm install -D tailwindcss@latest postcss@latest autoprefixer@latest

Setting Up The Modal Component

We start by importing Dialog from @headlessui/react and creating a simple modal that will appear in the upper left corner of the screen.

/Components/Drawer/index.tsx
import { Dialog } from "@headlessui/react";
import Button from "@app/components/Button";
type DrawerProps = {
title?: string;
description?: string;
children: React.ReactNode;
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function Drawer({
title = '',
description = '',
children,
isOpen,
setIsOpen,
}: DrawerProps) {
return (
<Dialog
unmount={false}
open={isOpen}
onClose={() => setIsOpen(false)}
className="fixed z-30 inset-0 overflow-y-auto"
>
<div className="flex w-3/4 h-screen">
<Dialog.Overlay
className="z-40 fixed inset-0 bg-black bg-opacity-30"
/>
<div className={`z-50 flex flex-col justify-between bg-gray-500 w-full
max-w-sm p-6 overflow-hidden text-left align-middle
shadow-xl`}>
<div>
<Dialog.Title
className="font-bold text-2xl md:text-4xl text-blue-400"
>
{title}
</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
{children}
</div>
<div className="self-center mt-10">
<Button onClick={() => setIsOpen(!isOpen)}>Close</Button>
</div>
</div>
</div>
</Dialog>
);
}

Let's go over the code line by line. <Dialog> is the main container for the model. We set unmount={false} for performance reasons. Our modal slides on/off screen and we do not want to mount/unmount it every time it needs to slide in/out. The state of our drawer (open/closed) is going to be handled in the parent component, so we pass isOpen and the function setIsOpen in to the Drawer component. Last we give it a fixed position at inset-0, a z-30 to make sure it always renders above our main content, and overflow-y-auto to make sure that it renders scrollbars if the children take up to much space.

Within the Dialog we have a <div> to keep the width of our drawer to 3/4 of the screen and the height to the entire screen. The <Dialog.Overlay> is the piece which greys out the main content and is at z-40 to place it just above the main Dialog container. It is just a black background with opacity 40.

The content of the drawer is composed of a flexbox in column order with the Dialog.Title and Dialog.Description placed to the left and all other items centered. There is a button at the bottom that can activate the parents setIsOpen function to close the dialog as well.


Setting Up The Parent Component

This is an outline of what setting up the parent component to the drawer looks like. The drawer can be open/closed with the button element of the parent component. With this implementation the drawer is closed whenever a link is clicked as well.

/components/Button/index.tsx
import { useState } from 'react';
import Drawer from '@app/components/Drawer';
import Button from '@app/components/Button';
export default function NavBar() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Drawer
isOpen={isOpen}
setIsOpen={setIsOpen}
title="Menu"
description="Try something new!"
>
<a href="/" onClick={() => setIsOpen(false)}>
Home
</a>
<a href="/blog" onClick={() => setIsOpen(false)}>
Blog
</a>
<a href="/projects" onClick={() => setIsOpen(false)}>
Projects
</a>
<a href="/contact" onClick={() => setIsOpen(false)}>
Contact
</a>
</Drawer>
<Button onClick={() => setIsOpen(!isOpen)}>
Menu
</Button>
</>
);
}

I have not included all of the nice to haves in this example, it shows you a basic outline for how to connect the logic for triggering the drawer from a parent button/page/navbar etc. In the component that you want to trigger the modal from you will include the <Drawer>. State is maintained with a react useState hook. The drawer is placed into the top of the component and whatever you want to display in it (usually links to other pages) are placed as children within it. The state isOpen and the function that controls it are passed into the Drawer. The button when clicked changed the isOpen state that has been passed into the drawer component.

/components/Button/index.tsx
export default function RoundedButton({
className,
children,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={`font-bold py-2 px-4 rounded inline-flex items-center
${className}`}
{...rest}
>
{children}
</button>
);
}

This is the button component which is used as the trigger to open/close the drawer. It is simple, customizable, and reusable. I also use it within the Drawer component as another way to close the drawer.


Creating the transition

The final step is to wrap our drawer in a Transition so that our drawer smoothly fly's in and out of the screen.

/components/Drawer/index.tsx
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Button from '@components/Button/index.tsx';
type DrawerProps = {
title?: string,
description?: string,
children: React.ReactNode,
isOpen: boolean,
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>
}
export default function Drawer({
title = '',
description = '',
children,
isOpen,
setIsOpen
}: DrawerProps) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
unmount={false}
onClose={() => setIsOpen(false)}
className="fixed z-30 inset-0 overflow-y-auto"
>
<div className="flex w-3/4 h-screen">
<Transition.Child
as={Fragment}
enter="transition-opacity ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-30"
entered="opacity-30"
leave="transition-opacity ease-out duration-300"
leaveFrom="opacity-30"
leaveTo="opacity-0"
>
<Dialog.Overlay className="z-40 fixed inset-0 bg-black" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<div
className={`flex flex-col justify-between bg-gray-500 z-50
w-full max-w-sm p-6 overflow-hidden text-left
align-middle shadow-xl rounded-r-2xl`}>
<div>
<Dialog.Title
className="font-bold text-2xl md:text-4xl text-blue-500"
>
{title}
</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
{children}
</div>
<div className="self-center mt-10">
<Button onClick={() => setIsOpen(!isOpen)}>Close</Button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition>
);
}

Most of this is explained fairly well in headless-ui Transition documentation. However there is one part which is not explained well. You need to use the entered prop on the Transition.Child for the Overlay or else the entire overlay will loose its opacity setting right after the transition and cover the main content in pitch black.

To break this down, we are wrapping our Dialog component in a Transition element. That element is now the one responsible for showing/hiding the Drawer (Dialog/Modal) so we hoise the isOpen prop to Transition. All transition does is add/remove className strings from the className of the main child component that it is wrapped in. In our case there are 2 separate transitions so we use Transition.Child to define each transition separately. We create the Transition and its children as a React Fragment because it isn't an element we want to display, it is just an element used to alter the children that are within it.

The first transition fades the opacity of the Overlay in from 0 to 30 and then back out again depending on the value of isOpen. The second transition slides the drawer component in from the left of the screen to its final resting position (inset-0) and then back out again (also based on the value of isOpen).