Pointer Grace Areas
- 文章發表於
- ...AI TranslatedAI 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 polygonfor (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 edgeconst 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 > yistrue) - Other vertex is below y (
yj > yisfalse)
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 straddlesEdge 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-Coordinatex < (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?
y8 │ 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)│└──────────────────────► x1 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: 8Ending y: 2Target y: 5How far have we dropped?From 8 down to 5 = 3 unitsTotal drop from A to B?From 8 down to 2 = 6 unitsFraction of the journey: 3/6 = 0.5 (halfway!)
Step 2: What's the x at this fraction?
Starting x: 2Ending x: 4Total x change: 4 - 2 = 2x_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/Outsideif (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 slightlyexactly at to the LEFT, givingcursor a bit more room
