Today we are going to be building a React hooks dropdown menu. This type of UI element can be found almost everywhere, and inspiration for this particular one came from Airbnb's header.
Given the title of this post, we'll be using React, but the markup, styles and general technique can be applied anywhere. It also assumes you have at least some knowledge of React hooks.
The Component#
First let's identify how we want this component to work. When our trigger button is clicked, we want to display our menu. When that same button is clicked again, or if the user clicks outside of the menu, we toggle it closed.
We'll start by creating a component named DropdownMenu.jsx
. Our component can only ever be in one of two states: active or inactive, and will be controlled by the useState()
hook.
Additionally, we'll want to use a React ref to be able to reference the dropdown menu itself. What we should have so far is:
const DropdownMenu = () => {const dropdownRef = useRef(null);const [isActive, setIsActive] = useState(false);return ();};
Next, lets create our JSX markup. We want to add a base container that will hold the trigger button and the dropdown. I won't go into much detail on the trigger button itself because it can contain any text/image/etc., but the important part is the onClick
event that will update the state from inactive to active.
const DropdownMenu = () => {const dropdownRef = useRef(null);const [isActive, setIsActive] = useState(false);const onClick = () => setIsActive(!isActive);return (<div className="menu-container"><button onClick={onClick} className="menu-trigger"><span>User</span><img src="https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/df/df7789f313571604c0e4fb82154f7ee93d9989c6.jpg" alt="User avatar" /></button></div>);};
The dropdown menu container will be a nav element and receive a class of active or inactive, depending on the current state. It'll be adjacent to the trigger button but still within the menu-container
div.
const DropdownMenu = () => {const dropdownRef = useRef(null);const [isActive, setIsActive] = useState(false);const onClick = () => setIsActive(!isActive);return (<div className="menu-container"><button onClick={onClick} className="menu-trigger"><span>User</span><img src="https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/df/df7789f313571604c0e4fb82154f7ee93d9989c6.jpg" alt="User avatar" /></button><nav ref={dropdownRef} className={`menu ${isActive ? 'active' : 'inactive'}`}><ul><li><a href="/messages">Messages</a></li><li><a href="/trips">Trips</a></li><li><a href="/saved">Saved</a></li></ul></nav></div>);};
Notice the use of our dropdownRef
constant. This is what allows us to keep track of our dropdown and we can use it to determine if a user has clicked outside of it. We will come back to this part shortly.
The CSS#
We'll mostly focus on the dropdown itself in this section, but the menu trigger button styles will be included for reference at the end.
We want to first start with the base menu-container
.
.menu-container {position: relative;}
Next, the dropdown menu itself.
.menu {background: #ffffff;border-radius: 8px;position: absolute;top: 60px;right: 0;width: 300px;box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);opacity: 0;visibility: hidden;transform: translateY(-20px);transition: opacity 0.4s ease, transform 0.4s ease, visibility 0.4s;}.menu.active {opacity: 1;visibility: visible;transform: translateY(0);}.menu ul {list-style: none;padding: 0;margin: 0;}.menu li {border-bottom: 1px solid #dddddd;}.menu li a {text-decoration: none;color: #333333;padding: 15px 20px;display: block;}
On the menu element, we add the opacity, translateY
and visibility properties so that we are able to transition the dropdown and hide it visually.
Before our menu becomes active, there is a small negative translateY
value set on it. Our active class sets this value to 0. And since it's a property we want to transition, we get a nice subtle animation.
The remaining styles are specific to the dropdown trigger.
.menu-trigger {background: #ffffff;border-radius: 90px;cursor: pointer;display: flex;justify-content: space-between;align-items: center;padding: 4px 6px;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);border: none;vertical-align: middle;transition: box-shadow 0.4s ease;}.menu-trigger:hover {box-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);}.menu-trigger span {font-weight: 700;vertical-align: middle;font-size: 14px;margin: 0 10px;}.menu-trigger img {border-radius: 90px;}
With all of this in place, you should now have a functioning menu that opens and closes with the trigger button.
Closing When Clicking Outside#
There is still one problem. The menu can't be closed without clicking the trigger button again, which is not a great user experience. It would be much better if the menu would close if you clicked the trigger OR clicked anywhere else besides inside of the menu.
In order to add this functionality, we need to first add a useEffect
hook. This hook will allow us to perform logic when the isActive
state changes.
const DropdownMenu = () => {const dropdownRef = useRef(null);const [isActive, setIsActive] = useState(false);const onClick = () => setIsActive(!isActive);useEffect(() => {}, [isActive]);return (...)}
Once the menu is active, we need a function that will determine if our menu was the element that was clicked on. We'll place it inside of the useEffect
.
useEffect(() => {const pageClickEvent = (e) => {console.log(e);};}, [isActive]);
Now we'll need a way to determine if the user is clicking something on the screen, and only while the menu is currently active. For that, we'll add an event listener and pass it the pageClickEvent
function we just created.
useEffect(() => {const pageClickEvent = (e) => {console.log(e);};// If the item is active (ie open) then listen for clicksif (isActive) {window.addEventListener('click', pageClickEvent);}}, [isActive]);
It's important to unset our event listener once the dropdown is closed. To do that, just return a function from useEffect
. This is a way to perform any cleanup.
useEffect(() => {const pageClickEvent = (e) => {console.log(e);};// If the item is active (ie open) then listen for clicksif (isActive) {window.addEventListener('click', pageClickEvent);}return () => {window.removeEventListener('click', pageClickEvent);}}, [isActive]);
You should now be seeing event data logged to your console when clicking around the screen when the dropdown is active.
We want to determine if the event target (i.e., the element that was clicked on) is a descendent of the dropdown menu itself. If it is, do nothing. Otherwise, we want to close the dropdown. This is where the dropdownRef
constant from earlier becomes relevant. Since a ref is a reference to a DOM element, we're able to determine this. Back to the pageClickEvent
function.
const pageClickEvent = (e) => {// If the active element exists and is clicked outside ofif (dropdownRef.current !== null && !dropdownRef.current.contains(e.target)) {setIsActive(!isActive);}};
All we need to do is add an if
statement that checks for two things. First, check if dropdownRef.current
isn't null. When a ref is available, the .current
value contains the underlying DOM element. We want to make sure there is an element before further asserting on it.
We also want to use the .contains
method and pass it the target element. If our dropdown does NOT contain the element that was clicked (meaning the click was outside the dropdown), then update the state to inactive and close the dropdown.
With this addition in place, the dropdown menu should be functioning exactly as we want it!
Further Abstraction#
Let's take what we just did a step further. What if we needed to the reuse the same logic of hiding an element when a user clicks outside of it? Fortunately, writing a custom hook can help us package up this logic and use it elsewhere.
We start by creating a new file named useDetectOutsideClick.js
which contains a function of the same name. Our function not only accepts an element to detect the clicks on or outside of the dropdown menu, but it also accepts an initial state value.
Let's then move all of the useState
and useEffect
logic from above into this new function.
import { useState, useEffect } from 'react';export const useDetectOutsideClick = (el, initialState) => {const [isActive, setIsActive] = useState(initialState);useEffect(() => {const pageClickEvent = (e) => {// If the active element exists and is clicked outside ofif (el.current !== null && !el.current.contains(e.target)) {setIsActive(!isActive);}};// If the item is active (ie open) then listen for clicksif (isActive) {window.addEventListener('click', pageClickEvent);}return () => {window.removeEventListener('click', pageClickEvent);}}, [isActive, el]);}
Next, we need to return something from this new hook. We'll return an array containing the isActive/setIsActive useState pair.
export const useDetectOutsideClick = (el, initialState) => {const [isActive, setIsActive] = useState(initialState);useEffect(() => { ... }return [isActive, setIsActive];}
This lets us move managing the state out of our component and into our hook. It now also allows our useEffect
logic to manage event listeners.
We'd use our new hook like this:
import { useDetectOutsideClick } = './useDetectOutsideClick/js';const DropdownMenu = () => {const dropdownRef = useRef(null);const [isActive, setIsActive] = useDetectOutsideClick(dropdownRef, false);const onClick = () => setIsActive(!isActive);return (...)}
We pass the dropdownRef
value and an initialState (false in this scenario) to our new hook. No other changes need to be made in our DropdownMenu component for this to work.
Summary#
As you can see, with just a few React hooks and some basic styles, we can create a nice looking dropdown menu. Not only that, we now have a React hook that we can reuse in all kinds of different ways!