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
  • submenu items contain children for the right panel
  • separator is 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 >= 0
  • v >= 0
  • u + 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 prev and 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
  • openLabel changes → 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:

  1. Track mouse movement direction using the previous frame velocity
  2. Build a triangle aimed at the submenu
  3. If the mouse is inside that triangle → user is “intending” to enter submenu
  4. While inside → prevent hover from switching to other submenu items
  5. Use a small close timeout to avoid flicker