BlogReactJS

How to make a simple dropdown menu component in React

Written by Codemzy on August 7th, 2023

Dropdowns are a common UI element that your React app will (probably) need. Here's a simple ReactJS dropdown menu that you can use, with code examples and how I built it.

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!

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.

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 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. Open Me!, "Open Me!" is the children.

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 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 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!

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.

dropdown overlap

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>
  );
};