I've built a few dropdown components with React over the years, and I've finally come up with a pattern that works. It's flexible enough that I can use it for whatever type of dropdown I need - a menu, a form, some text. But I'm able to use it to avoid having to repeat too much code.
We don’t use any external libraries for this, just useState
and useContext
. Simple!
I'll also useRef
for closing the dropdown when the user clicks outside (see the last section).
Here's how it will look:
See the Pen ReactJS Dropdown by Codemzy (@codemzy) on CodePen.
For the purpose of showing you something pretty, I've used tailwindcss for styles!
Dropdown Context with createContext
import React from 'react';
// dropdown context for open state
const DropdownContext = React.createContext({
open: false,
setOpen: () => {},
});
We will start with some context. This will allow our dropdown component to provide child components (more on that shortly) with information on if the dropdown is open or closed, and a function to open or close the dropdown.
Dropdown Component
And without further ado, I present to you... drumroll please 🥁... the Dropdown
component!!
// dropdown component for wrapping and providing context
function Dropdown({ children, ...props }) {
const [open, setOpen] = React.useState(false);
return (
<DropdownContext.Provider value={{ open, setOpen }}>
<div className="relative">{children}</div>
</DropdownContext.Provider>
);
};
Ummm, ok, I probably hyped that up a little more than necessary.
Because all this Dropdown
component does is return a position: relative;
div, with the DropdownContext
we created earlier.
But where is the button? And the dropdown?
To make this dropdown component super reusable, I’m going to make it a compound component. That means that I can pick and mix the elements I want to use for each dropdown I build. It also makes the content more customisable, instead of something like:
<Dropdown button="Open Me!" menu=[<Link to="/1" className="menu-item">Link 1<Link>, <Link to="/2" className="menu-item">Link 2<Link>, <Link to="/3" className="menu-item">Link 3<Link>] />
Which makes it hard to create something unique. Let’s say most of my dropdown menus will be simple lists, but I’ve got a couple of dropdowns where I want to add a form or a search.
With a compound component, I can create my normal dropdown menus:
<Dropdown>
<Dropdown.Button>Open Me!</Dropdown.Button>
<Dropdown.Contents>
<Dropdown.List>
<Dropdown.Item to="/1">Link 1<Dropdown.Item>
<Dropdown.Item to="/2">Link 2<Dropdown.Item>
<Dropdown.Item to="/3">Link 3<Dropdown.Item>
</Dropdown.List>
</Dropdown.Contents>
</Dropdown>
But I can also put whatever I want in the dropdown.
<Dropdown>
<Dropdown.Button>Open Me!</Dropdown.Button>
<Dropdown.Contents>
<SearchForm />
</Dropdown.Contents>
</Dropdown>
So let's create our dropdown button.
Dropdown Button Component
// dropdown button for triggering open
function DropdownButton({ children, ...props }) {
const { open, setOpen } = React.useContext(DropdownContext); // get the context
// to open and close the dropdown
function toggleOpen() {
setOpen(!open);
};
return (
<button onClick={toggleOpen} className="rounded px-4 py-2 font-bold text-white bg-gray-800 flex items-center">
{ children }
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width={15} height={15} strokeWidth={4} stroke="currentColor" className={`ml-2 ${open ? "rotate-180" : "rotate-0"}`}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
)
};
// optional - but I like this pattern to know it must be a child of Dropdown
Dropdown.Button = DropdownButton;
This might look like a fair bit of code - let's break it down.
First, we get the context.
const { open, setOpen } = React.useContext(DropdownContext); // get the context
We will be able to get the context, because DropdownButton
will be a child of Dropdown
(which provides the context).
When we click the button, we want to toggle to the dropdown. If it's closed, we can open it, and if it's open, we will close it. The toggleOpen
function does that, by setting open to the opposite of what it currently is setOpen(!open);
.
And we trigger the function when the button is clicked onClick={toggleOpen}
.
We pass through children
to the button, which will be whatever we pass for the button contents. E.g.
And I've added a nice little SVG icon from Heroicons, to indicate that the button is a dropdown. And if the dropdown is open, I flip the icon the other way up ${open ? "rotate-180" : "rotate-0"}
.
Finally, (and optionally), I assign the DropdownButton
component to Dropdown.Button
.
Dropdown.Button = DropdownButton;
I like this pattern for compound components because I can only use a DropdownButton
component inside a Dropdown
component since I need to have access to the context that Dropdown
provides.
And also when I type Dropdown.
in VS code, I'll see all the possible compound components I can use with Dropdown
.
You can go ahead and try what we have built so far...
import React from 'react';
function App() {
return (
<Dropdown>
<Dropdown.Button>Open Me!</Dropdown.Button>
</Dropdown>
);
};
Clicking the button doesn't do much yet, but you do get to see that icon flip, which is pretty satisfying!
Now let's create the actual dropdown!
Dropdown Content Component
// dropdown content for displaying dropdown
function DropdownContent({ children }) {
const { open } = React.useContext(DropdownContext); // get the context
return (
<div className={`absolute z-20 rounded border border-gray-300 bg-white overflow-hidden my-1 overflow-y-auto ${ open ? "shadow-md" : "hidden"}`}>
{ children }
</div>
);
};
// optional - but I like this pattern to know it must be a child of Dropdown
Dropdown.Content = DropdownContent;
This is pretty simple by design.
It returns a position: absolute;
div, with a little bit of styling. And if the DropdownContext
is open then it shows with a shadow ${ open ? "shadow-md" : "hidden"}
, but otherwise it's hidden (display: none;
).
I've added a decent Z-Index (z-index: 20;
) so that my dropdowns always display on top of other elements.
And that's it.
We want to keep DropdownContent
minimal so that we can keep the whole component as flexible as possible. I want to be able to have a dropdown menu, but I might want to use my dropdown for other things too - like a form or whatever.
I could put all the code for the menu in this component, but that's going to tie me up in knots if I want to use it for other types of dropdowns in the future.
But I will usually want to show a dropdown menu, so let's extend the Dropdown
component with some defaults for the menu.
Dropdown List Component
// dropdown list for dropdown menus
function DropdownList({ children, ...props }) {
const { setOpen } = React.useContext(DropdownContext); // get the context
return (
<ul onClick={() => setOpen(false)} className="divide-y divide-gray-200 text-gray-700" {...props}>
{ children }
</ul>
);
};
// optional - but I like this pattern to know it must be a child of Dropdown
Dropdown.List = DropdownList;
Usually, our dropdown menu will be a list, so let's make this a ul
by default. We can always switch things up with the as
prop if we want to later.
We can add some standard styles that will be common for all our dropdown menus here.
I also want to close the dropdown whenever I click inside it. Since the items in my dropdown list will be links and buttons, I don't want the dropdown to stay open after a click, so I've added an onClick
handler for that onClick={() => setOpen(false)}
.
Now we need to add the items!
Dropdown Item Component
import { Link } from "react-router-dom";
// dropdown items for dropdown menus
function DropdownItem({ children, ...props }) {
return (
<li>
<Link className="py-3 px-5 whitespace-nowrap hover:underline" {...props}>{ children }</Link>
</li>
);
};
// optional - but I like this pattern to know it must be a child of Dropdown
Dropdown.Item = DropdownItem;
Since I'll only use the Dropdown.Item
, I've wrapped in a li
element to be semantically correct. If you choose to use a div
in the Dropdown.Menu
you can remove the li
element here.
The above example assumes you are using React Router, and your dropdown menu will usually be the Link
component. You could also use a button
element or whatever your usual menu item will be.
return (
<li>
<button className="py-3 px-5 hover:underline" {...props}>{ children }</button>
</li>
);
Again, you can always use the as
prop to switch this up on an item-by-item basis.
How to close the dropdown on click outside
Ok, so far so good. We can use our Dropdown
and it all looks great.
import React from 'react'
function App() {
return (
<Dropdown>
<Dropdown.Button>Open Me!</Dropdown.Button>
<Dropdown.Content>
<Dropdown.List>
<Dropdown.Item>Dropdown Menu Item 1</Dropdown.Item>
<Dropdown.Item>Dropdown Menu Item 2</Dropdown.Item>
<Dropdown.Item>Dropdown Menu Item 3</Dropdown.Item>
<Dropdown.Item>Dropdown Menu Item 4</Dropdown.Item>
<Dropdown.Item>Dropdown Menu Item 5</Dropdown.Item>
</Dropdown.List>
</Dropdown.Content>
</Dropdown>
);
};
But when you have multiple dropdowns, you might run into issues depending on where they are positioned.
The best way to solve this problem is to automatically close the dropdown whenever we click away from it.
But that's hard to trigger from the component itself since we can't just add an onClick
handler. The click could happen anywhere else. We could do an onBlur
- but we would need that on every focusable element inside.
Instead, we can listen for a click, and close the dropdown if there's a click somewhere else. That means if the user clicks somewhere else on the page, or another dropdown, our dropdown will close.
// dropdown component for wrapping and providing context
function Dropdown({ children, ...props }) {
const [open, setOpen] = React.useState(false);
// click listeners for closing dropdown
React.useEffect(() => {
// show no dropdown
function close() {
setOpen(false);
};
// add or remove event listener
if (open) {
window.addEventListener("click", close);
}
// cleanup
return function removeListener() {
window.removeEventListener("click", close);
}
}, [open]); // only run if open state changes
return (
<DropdownContext.Provider value={{ open, setOpen }}>
<div className="relative m-1">{children}</div>
</DropdownContext.Provider>
);
};
That works pretty well for our menu dropdowns, with no more overlap, and no more two dropdowns being open at the same time. But, it will create a problem if we do have a form or other interactive elements in our dropdown.
Now, when we click on an input or other item in the dropdown, it will close!
We can solve this by creating a ref
for our dropdown, and checking if the click was outside the dropdown before running the close()
function.
Create the ref:
const dropdownRef = React.useRef(null);
Add the ref:
return (
<DropdownContext.Provider value={{ open, setOpen }}>
<div ref={dropdownRef} className="relative m-1">{children}</div>
</DropdownContext.Provider>
);
And then check if the click is outside the ref:
// close the dropdown
function close(e) {
if (!dropdownRef.current.contains(e.target)) {
setOpen(false);
}
};
Here's how the Dropdown
component looks now:
// dropdown component for wrapping and providing context
function Dropdown({ children, ...props }) {
const [open, setOpen] = React.useState(false);
const dropdownRef = React.useRef(null);
// click listeners for closing dropdown
React.useEffect(() => {
// close dropdown if click outside
function close(e) {
if (!dropdownRef.current.contains(e.target)) {
setOpen(false);
}
};
// add or remove event listener
if (open) {
window.addEventListener("click", close);
}
// cleanup
return function removeListener() {
window.removeEventListener("click", close);
}
}, [open]); // only run if open state changes
return (
<DropdownContext.Provider value={{ open, setOpen }}>
<div ref={dropdownRef} className="relative m-1">{children}</div>
</DropdownContext.Provider>
);
};