How href attribute of the anchor element got me stuck
I used anchors for every item in a nested menu. Items with no destination reloaded the page instead of only expanding the submenu. The problem was treating a navigation element as an action control.
Context
I was building a multi-level menu with the following requirements:
- Only the leaf menu item is an active link
- Clicking other non-leaf menu items should expand the next level items
What I did
I initially rendered every menu item with <a>. The only difference between leaf menu items and non-leaf menu items is that leaf menu items should have valid href which indicates the redirection destination. Therefore, I built a MenuItem component like this:
function MenuItem({ link, title, onClick = () => {} }) {
return (
<a href={link} onClick={onClick}>
{title}
</a>
)
}
The problem
Leaf menu items ✅
Leaf menu items work fine as the source { link: '/aaa', title: 'abc'} would be rendered as <a href='/aaa'> abc </a>
Non-leaf menu items ❌
For a non-leaf item, link was an empty string. The resulting HTML link, <a href="">, resolves against the document's base URL and normally navigates back to the current page. That navigation is separate from the click handler, which is why expanding the menu also triggered a reload. An anchor with no href is different: it is not a hyperlink.
href="#" is another distinct case. It navigates to an empty fragment and can change the URL or scroll position. Neither removing the destination nor substituting # gives an action the semantics and keyboard behavior of a button.
Use a button for expansion
Keep anchors for items with destinations. For a submenu toggle, use a button and communicate its expanded state:
type MenuItemProps = {
link?: string
title: string
expanded: boolean
submenuId: string
onToggle: () => void
}
function MenuItem({ link, title, expanded, submenuId, onToggle }: MenuItemProps) {
if (link) return <a href={link}>{title}</a>
return (
<button type="button" aria-expanded={expanded} aria-controls={submenuId} onClick={onToggle}>
{title}
</button>
)
}
The parent controls expanded and renders the submenu with the matching id. Test Tab, Enter, and Space on the toggle, and verify that the URL stays unchanged while the submenu opens. Leaf links should still navigate to their destinations.
Conclusion
An empty href still gives an anchor a navigation destination. In my menu, expanding a submenu was an action, so a button was the appropriate element. Keeping links for navigation and buttons for expansion removed the unwanted reload and gave each control the keyboard behavior users expect.