Closing a navigation menu in React
This is surprisingly easy.
I have a mobile nav, which has a hamburger icon to open it. Once it's open that icon changes to a cross. I want the menu to open when you click on the hamburger. I want it to close when you click on the cross or when you click (or tab, using the keyboard) outside of it.
Here's my starting Nav component that sets up a menu with four links:
const Nav = () => {
const navigation = [
{ link: '#', text: 'Link 1' },
{ link: '#', text: 'Link 2' },
{ link: '#', text: 'Link 3' },
{ link: '#', text: 'Link 4' },
];
return (
<nav>
<button className="menu-toggle">
<span className='menu hamburger'></span>
</button>
<ul className='menu-links'>
{navigation.map((nav) => (
<li key={nav.text}>
<a href={nav.link}>{nav.text}</a>
</li>
))}
</ul>
</nav>
);
};
The classnames are used in css to make it look pretty, position it and to make sure it only shows on screens too small to fit all of the links next to each other.
The first thing I'm going to do is to add a hook to keep track of whether the menu is open or closed.
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
The first line above sets the state hook up. The second sets it to be the reverse of what it is. So if it's true, then set it to false, and vice versa. The state is set to be false to start with, because when we load the page the menu shouldn't be showing.
And then add an onClick event to set that hook.
<button className="menu-toggle" onClick={toggle}>
I also want to add this to the links, so when you click on them, you go to a new page (or would if they weren't dummy links) and the menu closes
<a href={nav.link} onClick={toggle}>{nav.text}</a>
Now we have that set up, we can get it to do something. For this I am using CSS classes. The hamburger/cross classes tell me whether the hamburger or the cross icon should show:
<button className="menu-toggle" onClick={toggle}>
<span className={`menu ${isOpen ? 'cross' : 'hamburger'}`}></span>
</button>
This is saying that if isOpen is true (ie the menu is open) then show the cross icon. Otherwise show the hamburger icon.
I've also added something similar to the unordered list:
<ul className={`menu-links ${isOpen ? 'show' : ''}`}>
Here I have a CSS class set up called 'show'. The list is hidden by default, but if it also has the 'show' class, then it appears on the page.
And this all works. If you click on the hamburger icon, the menu appears. If you click on the cross icon, it disappears. Similarly if you click on a link, the menu disappears.
Which is fine, but it's possible to click outside of the menu and in that instance, you'd expect it to disappear. Similarly, if you navigate using the keyboard, after the last link it goes to the next selectable element on the page... which you then can't really see because the menu is in the way.
There is a way to do this, using the onBlur event.
onFocus runs when the element is focused. onBlur is the opposite - the element doesn't go blurry on screen, but if you think about looking through a camera lens, something is either focussed or blurry.
We can use it like this:
const hide = () => setIsOpen(false);
<a href={nav.link} onClick={toggle} onBlur={hide}>{nav.text}</a>
So now when happens is if you click on a link it will toggle the menu being showing and hiding. If you click outside the menu it will hide it. Perfect! Except it's not...
If you navigate using the keyboard, you'll go to the first link, then press tab to go to the next... except the menu vanishes!
This is because it doesn't go directly from one link to the next quite in the way it looks like it does. When you have the first link selected, it's focussed, so the menu is showing. The moment you press tab is unfocusses (blurs) that link and focusses the next. It's so quick that we can't tell, but the browser can. It means that the exact moment the first link blurs the hide function kicks in and hides the menu.
Fortunately, there's an easy solution - add a function to show the menu on focus:
const show = () => setIsOpen(true);
<a href={nav.link} onClick={toggle} onBlur={hide} onFocus={show}>{nav.text}</a>
At this point you might be wondering how this works - logically if the menu isn't showing, how you can you possibly focus it without clicking on the hamburger icon again?
The answer is that the browser is doing two things at the same time. Remember earlier I said when you tab it blurs the current link and focusses the next? It's going to want to hide the menu when you blur the current link, but as it does that, it's focussing the next link, so it shows the menu again. It's so quick that we can't see it briefly hiding the menu and showing it again.
You might also have read all this and thought, "Hang on, we've got all this stuff happening when we click on links, but the menu should always appear on larger screens." And you'd be right. The clever part here is in the CSS, where it's set only to hide the menu on mobile. On small screens, the 'menu-links' are set to hide by default. On larger screens the 'menu-links' are set to show by default. So whether or not it also includes the 'show' class, it won't do anything.
Arguably you're doing all this stuff to show and hide classes when you don't need to on larger screens, but there's something to be said for simplicity.
And could you just use the toggle function for onFocus and onBlur? Maybe. I didn't try. I like that it's clear that it's hiding the menu onBlur and showing it onFocus.
Here is the final code for the whole Nav component:
const Nav = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const hide = () => setIsOpen(false);
const show = () => setIsOpen(true);
const navigation = [
{ link: '#', text: 'Link 1' },
{ link: '#', text: 'Link 2' },
{ link: '#', text: 'Link 3' },
{ link: '#', text: 'Link 4' },
];
return (
<nav>
<button className="menu-toggle" onClick={toggle}>
<span className={`menu ${isOpen ? 'cross' : 'hamburger'}`}></span>
</button>
<ul className="menu-links">
{navigation.map(nav => (
<li key={nav.text}>
<a href={nav.link} onClick={toggle} onBlur={hide} onFocus={show}>
{nav.text}
</a>
</li>
))}
</ul>
</nav>
);
};