# Prediction Cone for Nested Menus

**SHOULD** · **ID:** `interactions-menu-prediction-cone` · **Category:** interactions
**Source:** [Rauno](https://interfaces.rauno.me/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-menu-prediction-cone

> SHOULD: A submenu that opens to the side must forgive diagonal pointer travel toward it. Do not swap or close it the instant the pointer leaves the parent row: use a safe-triangle/prediction cone (suppress sibling hover while the pointer aims at the open submenu) or, as a cheap approximation, a ~150ms close-delay that any re-entry into the submenu cancels.

When a submenu opens on hover, forgive diagonal pointer travel toward it instead of closing the instant the pointer leaves the parent row

The naive nested menu closes the submenu the moment the pointer leaves the parent item. That is exactly wrong, because the submenu opens to the side, so the shortest path to it runs diagonally across the sibling rows below the one you are pointing at. Each sibling the pointer crosses fires its own hover, swaps the open submenu out for its own, and the option you were reaching for vanishes before you arrive. The user learns to travel in an L — straight down the border, then across — which is the tell of a menu that does not forgive its own geometry. The fix has two well-known shapes. The "prediction cone" (or "safe triangle", the Amazon mega-menu trick Ben Kamens documented) reads pointer velocity and builds a triangle between the cursor and the top and bottom corners of the open submenu; while the pointer stays inside that triangle it is presumed to be aiming AT the submenu, so sibling hovers are suppressed for a short grace window. The cheaper approximation is a short close-delay (~100–200ms) on the submenu that any pointer re-entry cancels. This is the same forgiveness principle as interactions-forgiving-design and interactions-hover-content-persistence (hover targets must be reachable and not expire on a timer); it pairs with interactions-mousedown-dropdown, which handles the open, where this handles the traverse.

## Rule snippet

```tsx
onMouseEnter={(id) => { clearTimeout(t); t = setTimeout(() => setActive(id), 160); }}
// submenu panel: onMouseEnter={() => clearTimeout(t)}
```

## Bad — do not do this

`interactions-menu-prediction-cone-bad`

```tsx
import { useState } from 'react';

const MENU = [
  { id: 'products', label: 'Products', items: ['Analytics', 'Hosting', 'Storage'] },
  { id: 'pricing', label: 'Pricing', items: ['Plans', 'Enterprise', 'Calculator'] },
  { id: 'docs', label: 'Docs', items: ['Guides', 'API', 'Changelog'] },
];

export function MenuPredictionConeBad() {
  const [active, setActive] = useState<string>('products');
  const activeMenu = MENU.find((m) => m.id === active);

  return (
    <div className="w-full max-w-sm">
      <div className="flex gap-3">
        <ul className="w-28 shrink-0 rounded-lg border border-border bg-card p-1">
          {MENU.map((m) => (
            <li key={m.id}>
              <button
                type="button"
                // Bug: any sibling the pointer crosses on its way to the submenu
                // instantly hijacks it, so diagonal travel never lands.
                onMouseEnter={() => setActive(m.id)}
                className={`flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm ${
                  active === m.id ? 'bg-muted font-medium' : 'text-muted-foreground'
                }`}
              >
                {m.label}
                <span aria-hidden="true">›</span>
              </button>
            </li>
          ))}
        </ul>
        <ul className="min-h-[8.5rem] flex-1 rounded-lg border border-border bg-card p-1">
          {activeMenu?.items.map((it) => (
            <li key={it}>
              <button type="button" className="w-full rounded px-3 py-2 text-left text-sm hover:bg-muted">
                {it}
              </button>
            </li>
          ))}
        </ul>
      </div>
      <p className="mt-4 text-xs text-destructive">
        Reach for “Analytics”: moving diagonally crosses Pricing/Docs, which swap the submenu out from under you.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-menu-prediction-cone-good`

```tsx
import { useRef, useState } from 'react';

const MENU = [
  { id: 'products', label: 'Products', items: ['Analytics', 'Hosting', 'Storage'] },
  { id: 'pricing', label: 'Pricing', items: ['Plans', 'Enterprise', 'Calculator'] },
  { id: 'docs', label: 'Docs', items: ['Guides', 'API', 'Changelog'] },
];

export function MenuPredictionConeGood() {
  const [active, setActive] = useState<string>('products');
  const [showCone, setShowCone] = useState(false);
  const [cone, setCone] = useState<string | null>(null);
  const timer = useRef<number | null>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLUListElement>(null);

  const cancel = () => {
    if (timer.current) {
      window.clearTimeout(timer.current);
      timer.current = null;
    }
  };

  // Grace window: crossing a sibling only *schedules* a switch; reaching the open submenu
  // cancels it. The shaded triangle below visualises the safe region this approximates.
  const scheduleSwitch = (id: string) => {
    if (id === active) return cancel();
    cancel();
    timer.current = window.setTimeout(() => {
      setActive(id);
      timer.current = null;
    }, 160);
  };

  const onMove = (e: React.MouseEvent) => {
    if (!showCone || !wrapRef.current || !panelRef.current) return;
    const wr = wrapRef.current.getBoundingClientRect();
    const pr = panelRef.current.getBoundingClientRect();
    const x = e.clientX - wr.left;
    const y = e.clientY - wr.top;
    const left = pr.left - wr.left;
    // Apex at the cursor, base along the submenu's near (left) edge.
    setCone(`${x},${y} ${left},${pr.top - wr.top} ${left},${pr.bottom - wr.top}`);
  };

  const activeMenu = MENU.find((m) => m.id === active);

  return (
    <div className="w-full max-w-sm">
      <label className="mb-3 flex items-center gap-2 text-xs text-muted-foreground">
        <input
          type="checkbox"
          checked={showCone}
          onChange={(e) => {
            setShowCone(e.target.checked);
            if (!e.target.checked) setCone(null);
          }}
          className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        />
        Show the safe triangle
      </label>

      <div
        ref={wrapRef}
        onMouseMove={onMove}
        onMouseLeave={() => setCone(null)}
        className="relative flex gap-3"
      >
        {showCone && cone && (
          <svg className="pointer-events-none absolute inset-0 z-10 size-full overflow-visible" aria-hidden="true">
            <polygon points={cone} className="fill-primary/15 stroke-primary/60" strokeWidth={1} />
          </svg>
        )}
        <ul className="w-28 shrink-0 rounded-lg border border-border bg-card p-1">
          {MENU.map((m) => (
            <li key={m.id}>
              <button
                type="button"
                onMouseEnter={() => scheduleSwitch(m.id)}
                className={`flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm ${
                  active === m.id ? 'bg-muted font-medium' : 'text-muted-foreground'
                }`}
              >
                {m.label}
                <span aria-hidden="true">›</span>
              </button>
            </li>
          ))}
        </ul>
        <ul
          ref={panelRef}
          onMouseEnter={cancel}
          className="min-h-[8.5rem] flex-1 rounded-lg border border-border bg-card p-1"
        >
          {activeMenu?.items.map((it) => (
            <li key={it}>
              <button type="button" className="w-full rounded px-3 py-2 text-left text-sm hover:bg-muted">
                {it}
              </button>
            </li>
          ))}
        </ul>
      </div>
      <p className="mt-4 text-xs text-success">
        The same diagonal move now lands: while your pointer stays in the shaded triangle (aimed at the submenu),
        sibling hovers are held off.
      </p>
    </div>
  );
}
```

## References

- [Rauno Freiberg — interfaces.rauno.me](https://raw.githubusercontent.com/raunofreiberg/interfaces/main/README.md)
- [Ben Kamens — Breaking down Amazon’s mega dropdown](https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown)
- [MDN — Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
