Writing

Pointer Grace Areas

文章發表於
...
AI Translated

Why

When you have nested menus (a parent menu with submenus), users naturally move their cursor diagonally toward the submenu content

Sometimes we forget to handle this kind of detail. Although tiny, it can significantly impact users.

If we handle it badly, the user might fail on the first try when moving the cursor diagonally to navigate an open submenu. On their second attempt, they might slow down, but the submenu still closes. In this scenario, the user will either leave our site or switch to using the keyboard, which is not ideal.

The Challenge

The main challenge in delivering a better experience is understanding the user’s intent: whether they want to move to a submenu or exit the entire menu.

The Solution: Grace Areas

The solution is to calculate grace areas. This concept isn't new; Ben Kamens wrote the article 《Breaking down Amazon’s mega dropdown》 back in 2013, explaining how Amazon solves this issue.

Core concept

A grace area (also called "safe triangle" or "intent zone") is a geometric region where:

  • If the cursor is inside this region → User intends to reach submenu → Keep submenu open
  • If the cursor is outside this region → User intends something else → Close submenu

How to define the area

To define the area, we need to find five points. The first is the user's cursor; the other four are the anchors of the submenu (top, bottom, left, and right).

1 * (cursor + bleed)
2 ────── 3
│ │
│ SUBMENU│
│ │
5 ────── 4

But how can we determine if the pointer is within the area?

The Ray-Casting Algorithm

Also known as the "even-odd rule" or "Jordan curve theorem":

Concept

Cast a horizontal ray from the point to infinity (in any direction). Count how many times it crosses the polygon's edges:

  • Odd number of crossings means that point is inside
  • Even number of crossings means that point is outside

Try moving your cursor around the polygon above. The blue ray shows how the algorithm works in real-time. Watch the red intersection points—count them. If there is an odd number of intersections, you're inside. If it's even, you're outside.

Mathematical Implementation

function isPointInPolygon(point: Point, polygon: Polygon): boolean {
const { x, y } = point;
let inside = false;
// Loop through each edge of the polygon
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
// Check if the ray from point crosses this edge
const intersect = ((yi > y) !== (yj > y)) &&
(x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
}
Part 1. Vertical Bounds Check
(yi > y) !== (yj > y)

This checks if the edge straddles the horizontal line through point y:

  • One vertex is above y (yi > y is true)
  • Other vertex is below y (yj > y is false)
Edge A-B straddles y=5:
A (x=2, y=8) ← yi=8, yi > 5 is TRUE
│ y=5 ───────── (horizontal ray)
B (x=4, y=2) ← yj=2, yj > 5 is FALSE
(TRUE !== FALSE) = TRUE ✓ Edge straddles
Edge C-D doesn't straddle y=5:
C (x=6, y=8) ← yi=8, yi > 5 is TRUE
D (x=8, y=7) ← yj=7, yj > 5 is TRUE
(both above y=5)
(TRUE !== TRUE) = FALSE ✗ Edge doesn't straddle
Part 2. Intersection X-Coordinate
x < (xj - xi) * (y - yi) / (yj - yi) + xi

Part 1 only tells us the edge straddles our y-level. But that's not enough:

Case A: Edge is to our RIGHT Case B: Edge is to our LEFT
(Ray DOES cross it) (Ray does NOT cross it)
\ \
\ \
●─────\────────► \ ●────────►
\ \
\ \

Both edges pass Part 1 (they straddle the y-level), but only Case A should count as a crossing! So that's why we need Part 2, it answers the question: "Does the ray actually hit this edge, or does the edge pass to our left?"

Imagine you have a ruler along the horizontal line at y = 5. The edge is a diagonal line crossing this ruler somewhere. Where does it cross?

y
8 │ A(2,8)
│ \
7 │ \
│ \
6 │ \
│ \
5 │────────●──────────── ← The horizontal line y=5
│ \ Where does the edge cross?
4 │ \ Answer: at x=3
│ \
3 │ \
│ \
2 │ B(4,2)
└──────────────────────► x
1 2 3 4 5 6

Think of the edge as a path from A to B. As you walk from A to B:

  • Your x changes from 2 → 4 (total change: +2)
  • Your y changes from 8 → 2 (total change: -6)
Step 1: How far along the edge is y=5?
Starting y: 8
Ending y: 2
Target y: 5
How far have we dropped?
From 8 down to 5 = 3 units
Total drop from A to B?
From 8 down to 2 = 6 units
Fraction of the journey: 3/6 = 0.5 (halfway!)
Step 2: What's the x at this fraction?
Starting x: 2
Ending x: 4
Total x change: 4 - 2 = 2
x_intersect = xi + fraction × (xj - xi)
= xi + [(y - yi) / (yj - yi)] × (xj - xi)
= 2 + [(5-8)/(2-8)] × (4-2)
= 2 + 0.5 × 2
= 3
Step 3: Is the intersection to our RIGHT?

If cursor's x point is greater that intersect then means the ray doesn't cross this edge. if it's lower the ray crosses this edge.

Part 3. Toggle Inside/Outside
if (intersect) {
inside = !inside;
}

Each time we cross an edge, we toggle our inside/outside state:

  • Start: outside (false)
  • Cross 1st edge: inside (true)
  • Cross 2nd edge: outside (false)
  • Cross 3rd edge: inside (true)

Final state after checking all edges determines if point is inside.

Why Ray-Casting is Elegant

  • Works for any polygon: Convex, concave, irregular shapes
  • Efficient: O(n) where n is number of vertices
  • Numerically stable: Only requires basic arithmetic
  • Proven: Based on Jordan curve theorem (1800s mathematics)
  • No special cases: Same algorithm for all polygons

Apply the concept

From a UI/UX perspective, we will keep the grace area active for 100ms. The cursor position is a single point, but the user's intention has some tolerance, so we will add a small "bleed" to the cursor position. Lastly, we need to calculate the grace area when the pointer leaves.

onPointerLeave={(event) => {
const contentRect = submenu.getBoundingClientRect();
const side = 'right';
const bleed = side === 'right' ? -5 : +5;
const polygon = [
// Point 1: Cursor with bleed
{ x: event.clientX + bleed, y: event.clientY },
// Point 2: Top-near (closest edge to trigger)
{ x: contentRect.left, y: contentRect.top },
// Point 3: Top-far
{ x: contentRect.right, y: contentRect.top },
// Point 4: Bottom-far
{ x: contentRect.right, y: contentRect.bottom },
// Point 5: Bottom-near
{ x: contentRect.left, y: contentRect.bottom },
];
}

What Happens as You Move

Frame 1: Leave trigger, polygon created

Menu 1* Submenu
[View] → ╲ ┌─────────┐
╲ │ Copy │
╲ │ Paste │
╲───┴─────────┘

Frame 2: Moving diagonally (INSIDE polygon )

Menu Submenu
[View] → * ┌─────────┐
╲ │ Copy │
╲ │ Paste │
╲┴─────────┘
Is cursor in polygon? YES → Keep submenu open!

Frame 3: Reach destination

Menu Submenu
[View] → ┌─────────┐
│ Copy * │ ← Made it!
│ Paste │
└─────────┘

The "Bleed" Explained

The -5 or +5 bleed makes the polygon slightly more forgiving:

Without bleed: With bleed (-5 for right submenu):
Exact cursor * Shifted cursor *
│ │
│ ←─5px─┤
│ │
Polygon starts Polygon starts slightly
exactly at to the LEFT, giving
cursor a bit more room
If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Buy me a coffee