Aim through the triangle

Keep a submenu open while the pointer is heading toward it.

Guillermo Rauch@rauchg
A rite of passage in design engineering: implement the safety triangle for hover interactions
Mar 6, 2024View on X ↗

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.