Aim through the triangle
Keep a submenu open while the pointer is heading toward it.
Guillermo Rauch@rauchgA rite of passage in design engineering: implement the safety triangle for hover interactions
The triangle runs from the pointer to the submenu’s near edge; demo 2 compares a delay.
1 · Reach the Share submenu
Hover Share, then head straight for Embed at the bottom.
Hover winsBad
Submenu lost 0Reached 0
Safety triangleGood
Submenu lost 0Reached 0
Keys: → opens Share, ← or Esc closes. Touch has no hover, so a tap opens it.
// While the submenu is open, a sibling only wins if the pointer isn't heading for it.
menu.addEventListener("pointermove", (e) => {
const p = { x: e.clientX, y: e.clientY };
const r = submenu.getBoundingClientRect();
const heading = open && inTriangle(p, apex, { x: r.left, y: r.top }, { x: r.left, y: r.bottom });
if (heading) {
apex = { x: p.x - 4, y: p.y }; // follow the pointer
clearTimeout(grace);
grace = setTimeout(() => activate(itemUnder(p)), 300); // stopped? then it's a choice
} else {
activate(itemUnder(p));
}
});
const side = (p, a, b) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
const inTriangle = (p, a, b, c) => {
const d1 = side(p, a, b), d2 = side(p, b, c), d3 = side(p, c, a);
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
};
2 · A delay is not the fix
Reach Embed in each, then hop from Share to Rename.
No triangleLoses it
Lost 0Switch —
300ms delaySluggish
Lost 0Switch —
TriangleGood
Lost 0Switch —
Why it matters, pros and cons
Why it matters
The shortest path to a submenu item is a diagonal, and it crosses siblings.
Pros
- Diagonal paths work; no “staircase” along the edge.
- Switching to a sibling stays instant when you mean it.
- Invisible when it works, which is the point.
Cons
- Hover-only: touch and keyboard need tap and arrow keys.
- Too wide or too long a grace and menus feel sticky.
- Menu libraries (Radix, Base UI) ship it; don’t hand-roll twice.