Prediction cone
A predictive submenu dropdown with a mouse “safe triangle” that prevents accidental closing while moving toward nested menus inspired by Soren
Loading demo...
What this component does
You have a menu with submenus that doesn’t instantly close/switch when the user moves the mouse diagonally toward the submenu.
Instead, it creates a safe triangular region between:
- the user’s previous mouse position (instantaneous 1-frame velocity)
- the top-left of the submenu
- the bottom-left of the submenu
If the mouse is inside that triangle, you freeze submenu switching so the user can reach the submenu without frustration.
1) Data model: menu items
interface MenuItem {
type: "item" | "submenu" | "separator";
label?: string;
shortcut?: string;
danger?: boolean;
children?: string[];
}Why it matters
- One array drives both main menu + submenu rendering
submenuitems containchildrenfor the right panelseparatoris a purely visual row
2) Core math: point-in-triangle test (the “cone” detector)
function isPointInTriangle(px, py, ax, ay, bx, by, cx, cy): boolean;This function answers one question:
“Is the current mouse point inside the triangle?”
You compute barycentric coordinates (u, v) and check:
u >= 0v >= 0u + v < 1
Why it matters
- This is the entire “intent prediction” logic
- Everything else is UI + timing around this test
3) Mouse tracking with history (directional intent)
function useMouseTracking() {
const [pos, setPos] = useState({ x: 0, y: 0, prevX: 0, prevY: 0 });
// track current and previous frame mouse position
}You track:
x, y(current mouse)prevX, prevY(previous frame mouse point)
Why it matters
- The movement between
prevand current forms the triangle’s starting vertex - This creates intent: “the mouse is moving toward the submenu”
Key detail
By tracking just the 1-frame difference, the cone adapts perfectly instantly to mouse sudden stops or curves, avoiding a sluggish “trailing” safety zone.
4) Submenu geometry: where the triangle points to
You measure the submenu DOM rect:
useEffect(() => {
if (subMenuRef.current) {
setSubMenuRect(subMenuRef.current.getBoundingClientRect());
} else {
setSubMenuRect(null);
}
}, [openLabel]);Why it matters
- You can’t build the safe triangle without real submenu coordinates
openLabelchanges → submenu mounts → rect becomes available
5) Compute isInsideSafe (the prediction cone)
This is the real “behavior brain”:
const isInsideSafe = useMemo(() => {
if (!openLabel || !subMenuRect) return false;
const BUFFER = 30;
const dist = Math.hypot(mouse.x - mouse.prevX, mouse.y - mouse.prevY);
if (dist < 1) return false;
return isPointInTriangle(
mouse.x,
mouse.y,
mouse.prevX,
mouse.prevY,
subMenuRect.left,
subMenuRect.top - BUFFER,
subMenuRect.left,
subMenuRect.bottom + BUFFER,
);
}, [mouse, subMenuRect, openLabel]);What each piece means
BUFFER
- Adds forgiveness above and below the submenu
- Prevents the triangle from being too “tight”
dist < 1 early return
- If the mouse barely moved, don’t lock switching
- This prevents menus from feeling “stuck” when hovering precisely or stopped
Triangle vertices
- A = previous mouse position (
prevX,prevY) - B = submenu left/top (- buffer)
- C = submenu left/bottom (+ buffer)
So the cone always points at the submenu’s left edge.
6) Hover logic: don’t switch if inside safe area
const handleMouseEnterItem = (e, item) => {
if (isInsideSafe) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (item.type === "submenu" && item.label) {
setOpenLabel(item.label);
setActiveItemTop(e.currentTarget.offsetTop);
} else {
setOpenLabel(null);
}
};Why it matters
- This is where the prediction cone actually affects UX
- If mouse is inside triangle → ignore new hover targets → submenu stays open
7) Leave behavior: delayed close
const handleMouseLeaveMenu = () => {
timeoutRef.current = setTimeout(() => setOpenLabel(null), 150);
};And you cancel that when entering the submenu:
onMouseEnter={() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}}Why it matters
- Without the timeout you’d get submenu flicker
- This also makes it possible to “travel” across gaps
8) Submenu positioning: visually attached to the hovered row
style={{ transform: `translateY(${activeItemTop}px)` }}You store activeItemTop from the hovered submenu button:
setActiveItemTop(e.currentTarget.offsetTop);Why it matters
- Right panel lines up with the hovered submenu item
- Feels like one connected surface
9) Debug overlay: visualizing the triangle
MouseSafeArea draws the triangle polygon in an SVG:
- positioned as a fixed element
- triangle points are converted to local coordinates
{
openLabel && (
<MouseSafeArea
mouseX={mouse.x}
mouseY={mouse.y}
subMenuRect={subMenuRect}
debug={debug}
/>
);
}Why it matters
- Makes it easy to tune
BUFFER, history length, and distance threshold
The mental model
When submenu is open:
- Track mouse movement direction using the previous frame velocity
- Build a triangle aimed at the submenu
- If the mouse is inside that triangle → user is “intending” to enter submenu
- While inside → prevent hover from switching to other submenu items
- Use a small close timeout to avoid flicker