How I fixed a mega menu hover bug with the Framer Agent
Ever hovered a mega menu, moved toward the link you wanted, and watched the whole panel switch before you could reach it?
Your cursor briefly passes over a neighbouring navigation item on its way into the open menu. The menu treats that accidental crossing as a new hover and replaces the panel you meant to use.
It is a common issue in mega menus, including Framer sites such as Framer.com. I asked the Framer Agent to fix it, and within minutes it had built and applied a trajectory-based code override that keeps the current panel open while the cursor moves into it.
Here is how I fixed it, how you can use the same override, and how to prompt the Agent to build a version for your own navigation.
The problem: moving diagonally changes the menu
The bug is easy to reproduce.
Open a top-level navigation item.
Move diagonally toward a link in its menu panel.
Briefly cross a neighbouring navigation item.
Watch the menu switch to the wrong panel.
The menu only sees the cursor’s current position. It does not understand that the cursor is travelling toward the panel already on screen.
This is known as the diagonal problem. A common solution is the safe triangle: an invisible protected area between the active navigation item and its open panel. While the cursor moves through that area, other navigation items ignore hover.
Why I did not use a hover delay
A hover delay is the usual shortcut. Add a short delay before switching panels and quick accidental crossings stop causing problems.
It also makes intentional switching feel slow.
A delay cannot tell whether someone is moving into the menu panel or moving across the navigation. It only knows how long the cursor has been over an item.
I wanted the menu to stay stable during a diagonal move into the panel, while still switching immediately when someone moved sideways to another item.
The prompt I gave the Framer Agent
I started with a plain-language description of the issue:
“I have a hover mega menu in Framer. When I open a top-level navigation item and move quickly toward its submenu, my cursor sometimes crosses a neighbouring item and opens the wrong panel. Can you create a code override that prevents this, similar to a safe triangle?”
The Framer Agent created an override that tracks pointer velocity and projects the cursor’s path forward.
If that projected path will cross the bottom edge of the navigation row within the next 500 milliseconds, the override treats the cursor as moving toward the open panel. It then protects that movement for a moment.
What the override does
While the cursor is travelling into the open panel, the override:
Prevents sibling navigation items from receiving pointer events.
Keeps the active item interactive, so its panel stays open.
Cancels the guard when the cursor moves horizontally.
Restores normal hover behaviour when the cursor slows down or stops.
The important detail is that it does not use a fixed triangle or hardcoded menu dimensions. Each navigation item measures its position at runtime, so the same override works across different layouts and menu widths.
The code has four values worth tuning:
The minimum pointer speed required to activate the guard
How far ahead the cursor path is projected
How strongly upward movement cancels the guard
How long the guard remains active after the cursor stops

Let the Agent apply the override
I did not have to find every navigation item and attach the override myself.
After the Agent created it, I pointed it at the menu and said:
“Apply this override to every top-level navigation item in this mega menu. Do not apply it to links inside the submenu panels.”
The Agent found the relevant canvas elements and applied the override across the navigation.
If you use the override from this post, you can do the same. Add the code to your project, point the Agent at your menu, and ask it to apply the override to the top-level items.
You still keep full control. The override remains editable, and every applied connection stays visible on the canvas.
Keep the existing menu interactions in Framer
The override handles pointer geometry. Framer’s native interactions should still control which menu panel is open. I made two small changes to the existing interactions:
The active navigation item no longer closes its panel when the cursor leaves the item itself. The cursor needs to leave that item to reach the panel below it.
Each open menu state closes when the cursor leaves the full navigation area, including the menu panel.
That separation keeps the setup simple. The override protects the path from the navigation item to its panel. Framer still controls variants, hover styling, transitions, and closing behaviour.

Tune it with follow-up prompts
The first version worked, but I wanted to refine the feel of the interaction.
Instead of manually editing the code, I used short follow-up prompts:
“Make it slightly more aggressive on sideways and down movement.”
“Keep the current hover active based on the same trajectory values.”
“It does not immediately switch when I move side to side.”
The Agent adjusted the thresholds and replaced an earlier angle-based check with the trajectory projection. That made the menu stable while moving into a panel, without adding friction when switching across the navigation.
Start with the default values and test the menu. If it feels too cautious or too sensitive, describe what happens and what you want instead. The Agent can adjust the logic from there.
Debug the interaction visually
The most useful prompt came when I wanted to understand why the guard was activating:
“Visualize the safe hover area so I can debug it.”
The Agent added a temporary debug layer with a projected cursor path, a live velocity readout, and visual states for blocked navigation items.
That turned tuning into observation. I could see when the guard engaged, which items it blocked, and how the cursor’s direction affected the result.
Once the interaction felt right, I asked the Agent to remove the debug layer.
Try the live demo with the debug layer enabled here: https://dark-favorites-952985.framer.app/
Use the same approach in your own menu
You can copy the override and ask the Framer Agent to apply it to your navigation, or ask the Agent to create a version for your menu from scratch.
The key is to describe the intended behaviour clearly:
“Keep the current panel open while the cursor moves into it. Let users switch immediately when they move sideways to another navigation item.”
The Agent handles the implementation and repetitive canvas work. You can test the result, refine it with follow-up prompts, and keep the full interaction editable in Framer.
import { type ComponentType, forwardRef, startTransition, useCallback, useEffect, useRef, useState } from "react"
import { RenderTarget } from "framer"
const SPEED_THRESHOLD_PX_PER_MS = 0.15
const PROJECTED_EXIT_MS = 500
const UPWARD_CANCEL_ANGLE_DEGREES = 10
const DWELL_GRACE_MS = 150
type PointerSample = {
x: number
y: number
t: number
}
export function withSafeHover(Component): ComponentType {
return forwardRef(function WithSafeHover(props: any, ref) {
const [blocked, setBlocked] = useState(false)
const blockedRef = useRef(false)
const elementRef = useRef<Element | null>(null)
const historyRef = useRef<PointerSample[]>([])
const travelingRef = useRef(false)
const lastFastTimestampRef = useRef(0)
const prevInsideRef = useRef(false)
const allowInsideUnblockedRef = useRef(false)
const enteredDuringTravelRef = useRef(false)
const isCanvasRenderTarget =
typeof window !== "undefined" &&
typeof RenderTarget !== "undefined" &&
RenderTarget.current() === RenderTarget.canvas
const setBlockedIfChanged = useCallback((nextBlocked: boolean) => {
if (blockedRef.current === nextBlocked) return
blockedRef.current = nextBlocked
startTransition(() => {
setBlocked(nextBlocked)
})
}, [])
const setCombinedRef = useCallback(
(node: any) => {
if (node && typeof node.getBoundingClientRect === "function") {
elementRef.current = node
} else {
elementRef.current = null
}
if (typeof ref === "function") {
ref(node)
} else if (ref && typeof ref === "object" && "current" in ref) {
;(ref as { current: any }).current = node
}
},
[ref]
)
useEffect(() => {
if (typeof window === "undefined") return
if (isCanvasRenderTarget) return
const upwardCancelTanThreshold = Math.tan((UPWARD_CANCEL_ANGLE_DEGREES * Math.PI) / 180)
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return
const element = elementRef.current
if (!element) {
setBlockedIfChanged(false)
return
}
const now = event.timeStamp || performance.now()
const sample: PointerSample = { x: event.clientX, y: event.clientY, t: now }
const nextHistory = [...historyRef.current, sample]
.filter((point) => now - point.t <= 100)
.slice(-5)
historyRef.current = nextHistory
let speed = 0
let dx = 0
let dy = 0
let dt = 1
if (nextHistory.length >= 2) {
const first = nextHistory[0]
const last = nextHistory[nextHistory.length - 1]
dt = Math.max(last.t - first.t, 1)
dx = last.x - first.x
dy = last.y - first.y
speed = Math.hypot(dx, dy) / dt
}
const movingClearlyUpward =
dy < 0 && Math.abs(dy) > upwardCancelTanThreshold * Math.abs(dx)
const isFastEnough = speed >= SPEED_THRESHOLD_PX_PER_MS
const vy = dy / dt
const rect = element.getBoundingClientRect()
const pointerBelowRow = sample.y > rect.bottom
const projectedExitMs =
vy > 0 ? (rect.bottom - sample.y) / vy : Number.POSITIVE_INFINITY
const projectedToExitSoon = projectedExitMs < PROJECTED_EXIT_MS
const shouldStartTraveling =
isFastEnough && vy > 0 && (projectedToExitSoon || pointerBelowRow)
const shouldMaintainTraveling =
isFastEnough && !movingClearlyUpward && (pointerBelowRow || projectedToExitSoon)
const wasTraveling = travelingRef.current
let isTraveling = wasTraveling
if (!wasTraveling) {
isTraveling = shouldStartTraveling
if (isTraveling) lastFastTimestampRef.current = now
} else if (movingClearlyUpward) {
isTraveling = false
} else if (shouldMaintainTraveling) {
isTraveling = true
lastFastTimestampRef.current = now
} else if (isFastEnough) {
isTraveling = false
} else {
isTraveling = wasTraveling && now - lastFastTimestampRef.current < DWELL_GRACE_MS
}
const isInside =
sample.x >= rect.left &&
sample.x <= rect.right &&
sample.y >= rect.top &&
sample.y <= rect.bottom
if (!wasTraveling && isTraveling) {
allowInsideUnblockedRef.current = isInside && !blockedRef.current
enteredDuringTravelRef.current = false
}
if (isTraveling && !prevInsideRef.current && isInside) {
enteredDuringTravelRef.current = true
}
let shouldBlock = false
if (isTraveling) {
if (!isInside) {
shouldBlock = true
} else if (enteredDuringTravelRef.current) {
shouldBlock = true
} else if (allowInsideUnblockedRef.current) {
shouldBlock = false
}
}
if (!isTraveling) {
enteredDuringTravelRef.current = false
allowInsideUnblockedRef.current = false
}
setBlockedIfChanged(shouldBlock)
travelingRef.current = isTraveling
prevInsideRef.current = isInside
}
window.addEventListener("pointermove", onPointerMove, { passive: true })
return () => {
window.removeEventListener("pointermove", onPointerMove)
}
}, [isCanvasRenderTarget, setBlockedIfChanged])
if (isCanvasRenderTarget) {
return <Component ref={setCombinedRef} {...props} />
}
return (
<Component
ref={setCombinedRef}
{...props}
style={{
...props?.style,
...(blocked ? { pointerEvents: "none" } : null),
}}
/>
)
})
}
import { type ComponentType, forwardRef, startTransition, useCallback, useEffect, useRef, useState } from "react"
import { RenderTarget } from "framer"
const SPEED_THRESHOLD_PX_PER_MS = 0.15
const PROJECTED_EXIT_MS = 500
const UPWARD_CANCEL_ANGLE_DEGREES = 10
const DWELL_GRACE_MS = 150
type PointerSample = {
x: number
y: number
t: number
}
export function withSafeHover(Component): ComponentType {
return forwardRef(function WithSafeHover(props: any, ref) {
const [blocked, setBlocked] = useState(false)
const blockedRef = useRef(false)
const elementRef = useRef<Element | null>(null)
const historyRef = useRef<PointerSample[]>([])
const travelingRef = useRef(false)
const lastFastTimestampRef = useRef(0)
const prevInsideRef = useRef(false)
const allowInsideUnblockedRef = useRef(false)
const enteredDuringTravelRef = useRef(false)
const isCanvasRenderTarget =
typeof window !== "undefined" &&
typeof RenderTarget !== "undefined" &&
RenderTarget.current() === RenderTarget.canvas
const setBlockedIfChanged = useCallback((nextBlocked: boolean) => {
if (blockedRef.current === nextBlocked) return
blockedRef.current = nextBlocked
startTransition(() => {
setBlocked(nextBlocked)
})
}, [])
const setCombinedRef = useCallback(
(node: any) => {
if (node && typeof node.getBoundingClientRect === "function") {
elementRef.current = node
} else {
elementRef.current = null
}
if (typeof ref === "function") {
ref(node)
} else if (ref && typeof ref === "object" && "current" in ref) {
;(ref as { current: any }).current = node
}
},
[ref]
)
useEffect(() => {
if (typeof window === "undefined") return
if (isCanvasRenderTarget) return
const upwardCancelTanThreshold = Math.tan((UPWARD_CANCEL_ANGLE_DEGREES * Math.PI) / 180)
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return
const element = elementRef.current
if (!element) {
setBlockedIfChanged(false)
return
}
const now = event.timeStamp || performance.now()
const sample: PointerSample = { x: event.clientX, y: event.clientY, t: now }
const nextHistory = [...historyRef.current, sample]
.filter((point) => now - point.t <= 100)
.slice(-5)
historyRef.current = nextHistory
let speed = 0
let dx = 0
let dy = 0
let dt = 1
if (nextHistory.length >= 2) {
const first = nextHistory[0]
const last = nextHistory[nextHistory.length - 1]
dt = Math.max(last.t - first.t, 1)
dx = last.x - first.x
dy = last.y - first.y
speed = Math.hypot(dx, dy) / dt
}
const movingClearlyUpward =
dy < 0 && Math.abs(dy) > upwardCancelTanThreshold * Math.abs(dx)
const isFastEnough = speed >= SPEED_THRESHOLD_PX_PER_MS
const vy = dy / dt
const rect = element.getBoundingClientRect()
const pointerBelowRow = sample.y > rect.bottom
const projectedExitMs =
vy > 0 ? (rect.bottom - sample.y) / vy : Number.POSITIVE_INFINITY
const projectedToExitSoon = projectedExitMs < PROJECTED_EXIT_MS
const shouldStartTraveling =
isFastEnough && vy > 0 && (projectedToExitSoon || pointerBelowRow)
const shouldMaintainTraveling =
isFastEnough && !movingClearlyUpward && (pointerBelowRow || projectedToExitSoon)
const wasTraveling = travelingRef.current
let isTraveling = wasTraveling
if (!wasTraveling) {
isTraveling = shouldStartTraveling
if (isTraveling) lastFastTimestampRef.current = now
} else if (movingClearlyUpward) {
isTraveling = false
} else if (shouldMaintainTraveling) {
isTraveling = true
lastFastTimestampRef.current = now
} else if (isFastEnough) {
isTraveling = false
} else {
isTraveling = wasTraveling && now - lastFastTimestampRef.current < DWELL_GRACE_MS
}
const isInside =
sample.x >= rect.left &&
sample.x <= rect.right &&
sample.y >= rect.top &&
sample.y <= rect.bottom
if (!wasTraveling && isTraveling) {
allowInsideUnblockedRef.current = isInside && !blockedRef.current
enteredDuringTravelRef.current = false
}
if (isTraveling && !prevInsideRef.current && isInside) {
enteredDuringTravelRef.current = true
}
let shouldBlock = false
if (isTraveling) {
if (!isInside) {
shouldBlock = true
} else if (enteredDuringTravelRef.current) {
shouldBlock = true
} else if (allowInsideUnblockedRef.current) {
shouldBlock = false
}
}
if (!isTraveling) {
enteredDuringTravelRef.current = false
allowInsideUnblockedRef.current = false
}
setBlockedIfChanged(shouldBlock)
travelingRef.current = isTraveling
prevInsideRef.current = isInside
}
window.addEventListener("pointermove", onPointerMove, { passive: true })
return () => {
window.removeEventListener("pointermove", onPointerMove)
}
}, [isCanvasRenderTarget, setBlockedIfChanged])
if (isCanvasRenderTarget) {
return <Component ref={setCombinedRef} {...props} />
}
return (
<Component
ref={setCombinedRef}
{...props}
style={{
...props?.style,
...(blocked ? { pointerEvents: "none" } : null),
}}
/>
)
})
}
import { type ComponentType, forwardRef, startTransition, useCallback, useEffect, useRef, useState } from "react"
import { RenderTarget } from "framer"
const SPEED_THRESHOLD_PX_PER_MS = 0.15
const PROJECTED_EXIT_MS = 500
const UPWARD_CANCEL_ANGLE_DEGREES = 10
const DWELL_GRACE_MS = 150
type PointerSample = {
x: number
y: number
t: number
}
export function withSafeHover(Component): ComponentType {
return forwardRef(function WithSafeHover(props: any, ref) {
const [blocked, setBlocked] = useState(false)
const blockedRef = useRef(false)
const elementRef = useRef<Element | null>(null)
const historyRef = useRef<PointerSample[]>([])
const travelingRef = useRef(false)
const lastFastTimestampRef = useRef(0)
const prevInsideRef = useRef(false)
const allowInsideUnblockedRef = useRef(false)
const enteredDuringTravelRef = useRef(false)
const isCanvasRenderTarget =
typeof window !== "undefined" &&
typeof RenderTarget !== "undefined" &&
RenderTarget.current() === RenderTarget.canvas
const setBlockedIfChanged = useCallback((nextBlocked: boolean) => {
if (blockedRef.current === nextBlocked) return
blockedRef.current = nextBlocked
startTransition(() => {
setBlocked(nextBlocked)
})
}, [])
const setCombinedRef = useCallback(
(node: any) => {
if (node && typeof node.getBoundingClientRect === "function") {
elementRef.current = node
} else {
elementRef.current = null
}
if (typeof ref === "function") {
ref(node)
} else if (ref && typeof ref === "object" && "current" in ref) {
;(ref as { current: any }).current = node
}
},
[ref]
)
useEffect(() => {
if (typeof window === "undefined") return
if (isCanvasRenderTarget) return
const upwardCancelTanThreshold = Math.tan((UPWARD_CANCEL_ANGLE_DEGREES * Math.PI) / 180)
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return
const element = elementRef.current
if (!element) {
setBlockedIfChanged(false)
return
}
const now = event.timeStamp || performance.now()
const sample: PointerSample = { x: event.clientX, y: event.clientY, t: now }
const nextHistory = [...historyRef.current, sample]
.filter((point) => now - point.t <= 100)
.slice(-5)
historyRef.current = nextHistory
let speed = 0
let dx = 0
let dy = 0
let dt = 1
if (nextHistory.length >= 2) {
const first = nextHistory[0]
const last = nextHistory[nextHistory.length - 1]
dt = Math.max(last.t - first.t, 1)
dx = last.x - first.x
dy = last.y - first.y
speed = Math.hypot(dx, dy) / dt
}
const movingClearlyUpward =
dy < 0 && Math.abs(dy) > upwardCancelTanThreshold * Math.abs(dx)
const isFastEnough = speed >= SPEED_THRESHOLD_PX_PER_MS
const vy = dy / dt
const rect = element.getBoundingClientRect()
const pointerBelowRow = sample.y > rect.bottom
const projectedExitMs =
vy > 0 ? (rect.bottom - sample.y) / vy : Number.POSITIVE_INFINITY
const projectedToExitSoon = projectedExitMs < PROJECTED_EXIT_MS
const shouldStartTraveling =
isFastEnough && vy > 0 && (projectedToExitSoon || pointerBelowRow)
const shouldMaintainTraveling =
isFastEnough && !movingClearlyUpward && (pointerBelowRow || projectedToExitSoon)
const wasTraveling = travelingRef.current
let isTraveling = wasTraveling
if (!wasTraveling) {
isTraveling = shouldStartTraveling
if (isTraveling) lastFastTimestampRef.current = now
} else if (movingClearlyUpward) {
isTraveling = false
} else if (shouldMaintainTraveling) {
isTraveling = true
lastFastTimestampRef.current = now
} else if (isFastEnough) {
isTraveling = false
} else {
isTraveling = wasTraveling && now - lastFastTimestampRef.current < DWELL_GRACE_MS
}
const isInside =
sample.x >= rect.left &&
sample.x <= rect.right &&
sample.y >= rect.top &&
sample.y <= rect.bottom
if (!wasTraveling && isTraveling) {
allowInsideUnblockedRef.current = isInside && !blockedRef.current
enteredDuringTravelRef.current = false
}
if (isTraveling && !prevInsideRef.current && isInside) {
enteredDuringTravelRef.current = true
}
let shouldBlock = false
if (isTraveling) {
if (!isInside) {
shouldBlock = true
} else if (enteredDuringTravelRef.current) {
shouldBlock = true
} else if (allowInsideUnblockedRef.current) {
shouldBlock = false
}
}
if (!isTraveling) {
enteredDuringTravelRef.current = false
allowInsideUnblockedRef.current = false
}
setBlockedIfChanged(shouldBlock)
travelingRef.current = isTraveling
prevInsideRef.current = isInside
}
window.addEventListener("pointermove", onPointerMove, { passive: true })
return () => {
window.removeEventListener("pointermove", onPointerMove)
}
}, [isCanvasRenderTarget, setBlockedIfChanged])
if (isCanvasRenderTarget) {
return <Component ref={setCombinedRef} {...props} />
}
return (
<Component
ref={setCombinedRef}
{...props}
style={{
...props?.style,
...(blocked ? { pointerEvents: "none" } : null),
}}
/>
)
})
}