Better Context Menus With Safe Triangles

Better Context Menus With Safe Triangles

You’ve no doubt wrestled with menus that have nested menus before. I can’t count how many times I’ve hovered over a menu item that reveals another list of menu items, then tried to hover over that nested menu only to have the entire menu close on me.

That’s the setup for what I think is a pretty common issue when making menus — preventing nested menus from closing inadvertently. It’s not the users’ fault; leaving hover between menu levels is easy. It’s also not exactly the web’s fault; the menu is supposed to close if the pointer leaves the interactive area.

As long as the pointer is hovering over the SVG element, we have something we can use to maintain the nested menu’s open state.

Pointer Events

There are two steps we need to take to achieve this. First, we’ll create a “desired” path that connects our cursor to the submenu.

A triangular shape is the most straightforward path we can construct between a menu item and a nested menu. You can visualize what this triangle might look like in the image below. The green represents the safe area, indicating that it won’t trigger any onMouseLeave events. Conversely, the red area signifies that it will start the onMouseLeave event since we’re likely moving toward a sibling menu item.

I approached this by creating a SafeArea component in React that contains the SVG markup:

<svg
  style={{
    position: "fixed",
    width: svgWidth,
    height: submenuHeight,
    pointerEvents: "none",
    zIndex: 2,
    top: submenuY,
    left: mouseX - 2
  }}
  id="svg-safe-area"
>
  {/* Safe Area */}
  <path
    pointerEvents="auto"
    stroke="red"
    strokeWidth="0.4"
    fill="rgb(114 140 89 / 0.3)"
    d={
      `M 0, ${mouseY-submenuY} 
        L ${svgWidth},${svgHeight}
        L ${svgWidth},0 
        z`
    }
  />
</svg>

Also, to constantly update our safe triangle and position it appropriately, we need a mouse listener, specifically onmousemove. I relied on a React hook from Josh Comeau called useMousePosition in a useMousePosition.tsx file that provides the safe triangle component, designating the mouse position with mouseX and mouseY.

The Safe Triangle

The triangle is the SVG’s only path element. For this to work correctly, we must set the CSS pointer-events property to none, which we can do inline directly in the SVG. Then we set pointer-events to auto inline in the path element. This way, we stop propagating events when they are coming from the path element — the safe triangle — but not when events come from the SVG’s red area.

Let’s break down the path we are drawing, as it’s way more straightforward than it looks:

<path
  pointerEvents="auto"
  stroke="red"
  strokeWidth="0.4"
  fill="rgb(114 140 89 / 0.3)"
  d={
    `M 0, ${mouseY-submenuY} 
      L ${svgWidth},${svgHeight}
      L ${svgWidth},0 
      z`
  }
/>

We set the pointer-events property to auto to capture all mouse events, and it does not trigger the onMouseLeave event as long as the cursor is inside the path.

Next, we provide the path with some basic CSS styles for debugging purposes. This way, we can see the safe area while testing interactions.

The 0, ${mouseY-submenuY} part is the path’s starting point, designating the center of the SVG’s area.

Then we continue our path drawing with two lines: L ${svgWidth},${svgHeight} and L ${svgWidth},0. The former represents the first line (L) based on the SVG’s width and height, while the latter draws the second line (L) based on the SVG’s width.

The z part of the path is what makes everything work. z is what closes the path, making a straight line to the path’s starting point, preventing the need to draw a third line.

You can explore the path in more detail or adjust it using this SVG path editor.

There Are Some Gotchas

This is a relatively simple solution on purpose. There are some situations where this approach may be too simple, and you will need another creative solution, particularly if you’re not working in React like me.

For example, what if the user’s pointer moves diagonally and touches a different menu item? This approach does not capture that interaction to prevent the current nested menu from closing, but that might not be what you want it to do. Perhaps you want the nested menu to close and need to adjust the SVG with a different shape. An “easy” way to solve this is to debounce a cleanup function so that, on every mouse movement, you call the cleanup function. And after some number of milliseconds have passed without a mouse movement, you would remove the SVG element, and the sibling listeners would trigger as expected.

Another example is the navigation paths. A triangle is terrific but might not be the ideal shape for your menu and how it is designed. After doing some of my own tests, I’ve found that a curved path tends to be more effective, closer to Needle’s approach for a safe area:

Needle’s Context Menu Safe Area paths. (Large preview) Wrapping Up

As you now know, coming up with a solution for nested menus that reveal on hover is more of a challenge than it looks on the surface. Whether a hover-based approach and the clicks it saves are worth the additional considerations that make a better user experience versus a click-based approach is totally up to you. If you go with a menu that relies on a mouse hover to reveal a nested menu, you now have a resource that enhances its usability.

What about you? Is this a UX challenge you’ve struggled with? Have you attempted to solve it differently? Is the “safe triangle” concept effective for your particular use case? I’d love to know in the comments!