Drawer Component With Headless-UI
Read time: 15 min.
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.
$> npm install @headlessui/react
$> 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.tsximport { 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 (<Dialogunmount={false}open={isOpen}onClose={() => setIsOpen(false)}className="fixed z-30 inset-0 overflow-y-auto"><div className="flex w-3/4 h-screen"><Dialog.OverlayclassName="z-40 fixed inset-0 bg-black bg-opacity-30"/><div className={`z-50 flex flex-col justify-between bg-gray-500 w-fullmax-w-sm p-6 overflow-hidden text-left align-middleshadow-xl`}><div><Dialog.TitleclassName="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.tsximport { 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 (<><DrawerisOpen={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.tsxexport default function RoundedButton({className,children,...rest}: React.ButtonHTMLAttributes<HTMLButtonElement>) {return (<buttonclassName={`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.tsximport { 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}><Dialogunmount={false}onClose={() => setIsOpen(false)}className="fixed z-30 inset-0 overflow-y-auto"><div className="flex w-3/4 h-screen"><Transition.Childas={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.Childas={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"><divclassName={`flex flex-col justify-between bg-gray-500 z-50w-full max-w-sm p-6 overflow-hidden text-leftalign-middle shadow-xl rounded-r-2xl`}><div><Dialog.TitleclassName="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
).