No more menu rage: Smooth navigation with useSafeArea

In this article
For Jasmeet Singh, Software Engineer II on Rippling's Web Design Systems team — a common user frustration became clear. Navigating multi-level menus often led to accidental closures. Manual fixes were time-consuming and prone to error, and existing solutions didn't address the specific nuances of diagonal mouse movement. Recognizing the gap, he set out to build a tailored solution. Discover Jasmeet’s insights on creating useSafeArea and how it now ensures seamless menu interactions for Rippling users.
Have you ever tried to navigate a dropdown menu with submenus, only to have the submenu disappear the moment your mouse cursor strayed slightly off the intended path? If you've used any modern web application with hierarchical navigation, you've likely experienced this frustrating UX problem. Today, I want to share my journey of solving this challenge by implementing a custom React hook called useSafeArea.
What are nested menus?
Nested menus are hierarchical user interface components that reveal additional menu items when hovering over or clicking a parent item. Think of the classic dropdown menus you see in:
Navigation bars with category submenus
Context menus in applications like file explorers
Settings panels with nested configuration options
Multi-level dropdown selectors




These menus are ubiquitous in modern web applications because they provide an efficient way to organize complex hierarchical information without cluttering the interface
How nested menus are generally implemented
The traditional approach to nested menus typically involves:
Hover-based triggers: Parent items open submenus on mouse enter
Timer-based closing: Submenus close after a delay when the mouse leaves
While this works for simple cases, it creates a significant usability issue when users need to navigate diagonally between menu levels.
The core problem: Diagonal mouse movement
The fundamental issue with traditional nested menu implementations is that they don't account for natural mouse movement patterns. When a user wants to navigate from a parent menu item to a submenu, they rarely move their mouse in a perfectly straight line. Instead, they often move diagonally, which can inadvertently trigger the menu's close behavior.

Why This Happens
Geometric reality: The shortest path between two points is a straight line, but users naturally move their mouse in diagonal motions
Timing conflicts: The delay between leaving the parent and entering the submenu can cause premature closure
Precision requirements: Users must navigate with pixel-perfect accuracy to avoid triggering close events
This creates a frustrating user experience where menus close unexpectedly, forcing users to restart their navigation multiple times.
Enter the safe area concept
The "safe area" (also known as a "safe triangle") is a UX pattern that creates an invisible zone where the user's mouse can travel without triggering the menu close behavior. This zone is calculated based on the user's mouse movement direction and intent.
The key insight is: If the user is moving toward the submenu, keep it open even if they temporarily leave the parent item's boundaries.
While several open source implementations use SVG overlays and event propagation to calculate the safe zone, we chose a lightweight alternative that relies solely on real-time mouse position tracking, offering greater flexibility and improved performance for our use case.
Key elements for safe area navigation
To implement an effective safe area solution, we need to track several key elements:
1. Target element
The main menu item that reveals the submenu on hover typically a <div>, <li>, or <button>. Tracking mouse entry and exit on this element helps detect when a navigation attempt starts or resets.
2. Content element
The submenu that appears next to the target. It may include nested items. Crucially, it shouldn’t close immediately when the mouse leaves the target if the user is moving toward it.
3. Mouse position tracking
We track the current and previous mouse coordinates to infer movement direction. If the user is heading toward the submenu, we delay closing to allow smooth navigation.
4. Spatial relationships
By calculating the bounding boxes of the target and content, we understand their position and distance. This helps determine whether the user’s mouse is moving in a valid path toward the submenu.


Building the hook
A React hook is a reusable function that lets you "hook into" React features like state, context, refs, and lifecycle behavior inside functional components. Hooks help keep logic modular and easy to share across components.
Determining content direction
The first step is to detect the position of the submenu relative to the target element. This helps determine the direction the user is likely to move in, allowing us to better evaluate their intent as they begin navigating toward the content.
To do this, we calculate the center points of both the target and content elements and compare their positions to determine the relative direction.
1
2
3
4
5
6
7
8
9
export function getContentDirection(
targetRect: Pick<DOMRect, 'left' | 'right'>,
contentRect: Pick<DOMRect, 'left' | 'right'>,
): Direction {
const targetCenter = (targetRect.left + targetRect.right) / 2;
const contentCenter = (contentRect.left + contentRect.right) / 2;
return contentCenter >= targetCenter ? DIRECTION.RIGHT : DIRECTION.LEFT;
}Left side submenu

Right side submenu

Mouse event listeners
To determine if the user is moving toward the submenu, we need to track their mouse position globally, not just over specific elements. This is especially important during diagonal movement, where the cursor may briefly leave both the target and content areas.
We achieve this by listening to mousemove events on the window, allowing continuous tracking of cursor coordinates. This ensures that even transient movement patterns are captured, improving our ability to detect user intent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const useMousePosition = (callback?: (pos: MousePosition) => void
) => {
const mousePositionRef = useRef<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
mousePositionRef.current.x = ev.clientX;
mousePositionRef.current.y = ev.clientY;
if (callback) {
callback(mousePositionRef.current);
}
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, [callback]);
return mousePositionRef.current;
};Movement prediction
Now that we have both the submenu's direction and real-time mouse tracking in place, we can start evaluating whether the user is intentionally moving toward the submenu.
To do this, we check two key conditions:
Vertical alignment: Is the cursor within the vertical bounds of the submenu?
Horizontal direction: Is the mouse moving toward the submenu based on its position (left or right of the target)?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const getIsMovingTowardsContent = (
mousePos: { x: number; y: number },
prevMousePos: { x: number; y: number },
contentRect: DOMRect,
direction: Direction,
) => {
const isWithinContentVerticalBounds =
mousePos.y >= contentRect.top && mousePos.y <= contentRect.bottom;
let isMovingHorizontallyTowardContent = false;
if (direction === DIRECTION.RIGHT) {
isMovingHorizontallyTowardContent =
mousePos.x >= prevMousePos.x && mousePos.x <= contentRect.left;
} else {
isMovingHorizontallyTowardContent =
mousePos.x <= prevMousePos.x && mousePos.x >= contentRect.right;
}
return isWithinContentVerticalBounds && isMovingHorizontallyTowardContent;
};
useSafeArea
With all the building blocks in place, we can now combine them into the useSafeArea hook.
At its core, the hook maintains two refs: one for the target element and one for the content element. While also managing the internal state to track when the cursor is in a safe zone.
This hook acts as the central logic hub, orchestrating all the conditions we've defined to ensure smooth and intentional navigation between nested menu elements.
1
2
3
4
5
6
7
8
export const useSafeArea = <
TargetElement extends HTMLElement = HTMLElement,
ContentElement extends HTMLElement = HTMLElement,
>(): {
safeZone: boolean;
targetRef: React.RefObject<TargetElement>;
contentRef: React.RefObject<ContentElement>;
} => { /* safe zone computation */ };Usage of hook in your component
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import { useSafeArea } from './useSafeArea'; // your custom hook
export function NestedMenu() {
const { safeZone, targetRef, contentRef } = useSafeArea();
return (
<div className="menu-wrapper">
<div ref={targetRef} className="menu-trigger">
Hover me
</div>
{safeZone && (
<div ref={contentRef} className="submenu-content">
Submenu content
</div>
)}
</div>
);
}Detecting the safe area
There are three key zones where the user is considered to be in the “safe area”:
The target element — where the interaction begins.
The content element — the visible submenu the user is navigating toward.
The diagonal movement zone — an invisible triangle between the two, where the mouse is moving toward the submenu.
To check if the user is within the target or content element, we use a geometric helper that compares the mouse position against the element's bounding box.
1
2
3
4
5
function isPointInsideRect(point: { x: number; y: number }, rect: DOMRect) {
return (
point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
);
}For diagonal movement, the getIsMovingTowardsContent function helps determine whether the user is actively moving toward the submenu. By combining these checks, we can reliably detect when the user is in the safe zone and prevent the submenu from closing prematurely during natural navigation.
If the user is in any one of the three safe areas, we mark the safe zone as active. Once the user enters the content area, we stop checking for directional movement, since the goal has been achieved. This prevents unnecessary computation and avoids false negatives.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
export const useSafeArea = <
TargetElement extends HTMLElement = HTMLElement,
ContentElement extends HTMLElement = HTMLElement,
>(): {
safeZone: boolean;
targetRef: React.RefObject<TargetElement>;
contentRef: React.RefObject<ContentElement>;
} => {
const targetRef = useRef<TargetElement | null>(null);
const contentRef = useRef<ContentElement | null>(null);
const [safeZone, setSafeZone] = useState(false);
const [reachedContent, setReachedContent] = useState(false);
const prevMousePos = useRef({ x: 0, y: 0 });
/**
* Main mouse move handler that evaluates whether the cursor
* is within a safe area or moving toward the submenu
*/
const handleMouseMove = useCallback(
({ x: mouseX, y: mouseY }: { x: number; y: number }) => {
const targetRect = targetRef.current?.getBoundingClientRect();
const contentRect = contentRef.current?.getBoundingClientRect();
// Determine whether the content is to the left or right of the target
const direction =
targetRect && contentRect ? getContentDirection(targetRect, contentRect) : DIRECTION.RIGHT;
let inTarget = false;
let inContent = false;
let isMovingTowardsContent = false;
// Check if the mouse is inside the target element
if (targetRect) {
inTarget = isPointInsideRect({ x: mouseX, y: mouseY }, targetRect);
}
// Check if the mouse is inside the content element
if (contentRect) {
inContent = isPointInsideRect({ x: mouseX, y: mouseY }, contentRect);
}
// If mouse has not reached the submenu yet, check if it's moving toward it
if (targetRect && contentRect && !reachedContent) {
isMovingTowardsContent = getIsMovingTowardsContent(
{ x: mouseX, y: mouseY },
prevMousePos.current,
contentRect,
direction,
);
}
// Determine if we're in a safe area
if (inTarget || inContent || isMovingTowardsContent) {
setSafeZone(true);
// If the mouse has reached the submenu, we update the state
if (inContent) {
setReachedContent(true);
}
// If the mouse returns to the target, reset the reachedContent state to restart the movement calculations if user moves again
if (inTarget) {
setReachedContent(false);
}
} else {
// If not in any of the safe zones, deactivate the safe state
setSafeZone(false);
}
// Update previous mouse position for next movement comparison
prevMousePos.current = { x: mouseX, y: mouseY };
},
[reachedContent],
);
// Register the mouse movement listener
useMousePosition(handleMouseMove);
// Return the refs and the current safeZone state
return { safeZone, targetRef, contentRef };
};Key features
Granular Control with Refs: The hook exposes targetRef and contentRef, giving you direct access to the DOM elements involved in the interaction. This enables accurate hit-testing and lays the foundation for supporting more complex nested menu structures in the future.
Optimized for Performance: Transient values like mouse position and timeouts are stored in useRef, preventing extra renders and keeping the hook lightweight. This means your components remain snappy even during rapid user interaction.
One-Way State Transitions: State transitions — like setting reachedContent when the user enters the submenu — are one-way and deterministic. This avoids flickering or edge-case bugs where the submenu might reopen or close unexpectedly.
Future-Proof Architecture: With clear separation of concerns and a modular design, the hook is built to scale. Whether you want to support vertical submenus, introduce time-based thresholds, or handle mobile gestures, extending the logic will feel intuitive and maintainable.
Conclusion
Building a reliable and frustration-free nested menu experience is trickier than it looks — but it’s also deeply satisfying when you get it right. With useSafeArea, I set out to solve a deceptively simple problem with a combination of geometry, prediction, and React best practices. The result is a lightweight, performant, and extensible hook that lets users navigate menus the way they naturally do without fear of premature closure. If you’ve ever raged at a disappearing submenu, this one’s for you.
Disclaimer
Rippling and its affiliates do not provide tax, accounting, or legal advice. This material has been prepared for informational purposes only, and is not intended to provide or be relied on for tax, accounting, or legal advice. You should consult your own tax, accounting, and legal advisors before engaging in any related activities or transactions.
Hubs
Author

Jasmeet Singh
Software Engineer II
Jasmeet Singh is part of Rippling’s Web Design Systems team, building the foundational components that power intuitive and modern user experiences across the platform. When he’s not engineering scalable UI systems, he’s either smashing birdies on the badminton court or out trekking in the mountains, always chasing the next adventure.
Explore more
See Rippling in action
Increase savings, automate busy work, and make better decisions by managing HR, IT, and Finance in one place.

















![[Blog] | Featured image | From Copy‑Paste Chaos to Cohesive Mobile CI/CD | .jpg](http://images.ctfassets.net/k0itp0ir7ty4/5RBVY71hlbtWfqEq3NOrOm/523bb97cea2ab7e1d9d6b89f11bc74db/develop_app_generic_img-scaled.jpg)
1 2 3 4 5// Traditional approach const handleMouseEnter = () => setIsOpen(true); const handleMouseLeave = () => { setTimeout(() => setIsOpen(false), 200); };