plans/issues/issue-2591.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report — do not improvise. When done, update this issue's row in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2591 --jq .state→open.git diff --stat 42bfbe3ed..HEAD -- packages/framer-motion/src/gestures/drag/ packages/framer-motion/src/gestures/hover.tsIfVisualElementDragControls.tschanged, re-verify the excerpts below. If the drag gesture has MOVED to motion-dom (plans 019/020 landed — checkls packages/motion-dom/src/gestures/drag/VisualElementDragControls.ts 2>/dev/null), STOP and report: the insertion points in this plan must be re-localized.
42bfbe3ed, 2026-06-11With drag + dragConstraints (or dragSnapToOrigin), releasing a drag
animates the element back toward its constraint/origin. If that animation
moves the element out from under a stationary cursor, browsers fire NO
pointer events — so the whileHover state sticks until the user happens to
move the mouse over and off the element again. The reporter's repro steps are
fully self-describing (sandbox is Cloudflare-blocked, not needed): drag the
element, release, element springs away, hover styles remain.
packages/framer-motion/src/gestures/hover.ts — HoverGesture feature.
handleHoverEvent(node, event, "End") (lines 4–20) does two things:
node.animationState.setActive("whileHover", false) and fires
props.onHoverEnd. Hover start/end is driven purely by
pointerenter/pointerleave via motion-dom's hover()
(packages/motion-dom/src/gestures/hover.ts) — nothing re-checks hover
validity when the element moves.packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts:
stop(event?, panInfo?) (lines 267–282): called on pointer up; calls
this.startAnimation(velocity) which animates back to constraints
(startAnimation, lines 458–512, returns
Promise.all(momentumAnimations).then(onDragTransitionEnd)).this.latestPointerEvent (line 89) holds the last PointerEvent
(has .clientX/.clientY) but is nulled in onSessionEnd (lines
227–235) right after stop() returns — capture coordinates inside
stop() before they're gone.this.visualElement; its DOM element
is this.visualElement.current; animation state:
this.visualElement.animationState (see usage at line 175–176:
animationState.setActive("whileDrag", true)).stop(); when it
finishes, the element is at its settled position — that is the moment to
hit-test the cursor.getContextWindow(this.visualElement) (imported, used at line 258) gives
the correct window for iframe contexts.| Purpose | Command | Expected |
|---|---|---|
| Build | yarn build (repo root) | exit 0 |
| Cypress (React 18) | see CLAUDE.md recipe: start dev/react Vite on a random port, then cd packages/framer-motion && cypress run --headed --config baseUrl=http://localhost:$PORT --spec cypress/integration/drag-hover-reset.ts | all pass |
| Cypress (React 19) | same with dev/react-19 + --config-file=cypress.react-19.json | all pass |
| Unit tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/drag" | all pass |
Run Cypress in the foreground; capture output with tail -60 on first run.
In scope:
packages/framer-motion/src/gestures/drag/VisualElementDragControls.tsdev/react/src/tests/drag-hover-reset.tsx (create)packages/framer-motion/cypress/integration/drag-hover-reset.ts (create)Out of scope:
packages/motion-dom/src/gestures/hover.ts — do not add hit-testing into
the generic vanilla hover() gesture; the bug is specific to drag-induced
movement and the fix belongs where the movement is known to happen.onHoverEnd user callbacks with a fake event — only
reset the whileHover animation state. (Document this limitation in the
PR; firing callbacks with a synthetic stale event is worse than not firing.)fix/issue-2591-hover-reset-after-draggh pr create; gh pr edit is broken — use
gh api -X PATCH repos/motiondivision/motion/pulls/<n> -f body=... for edits.Create dev/react/src/tests/drag-hover-reset.tsx exporting App:
import { motion } from "framer-motion"
import { useRef } from "react"
export const App = () => {
const constraints = useRef<HTMLDivElement>(null)
return (
<div ref={constraints} style={{ width: 200, height: 200, position: "relative" }}>
<motion.div
data-testid="draggable"
drag
dragConstraints={constraints}
dragElastic={0.5}
dragMomentum={false}
initial={{ backgroundColor: "rgb(255, 0, 0)" }}
whileHover={{ backgroundColor: "rgb(0, 255, 0)" }}
transition={{ duration: 0 }}
style={{ width: 100, height: 100 }}
/>
</div>
)
}
Create packages/framer-motion/cypress/integration/drag-hover-reset.ts:
sequence — pointerenter on the draggable (hover starts; assert background
is green), pointerdown at its center, pointermove far outside the
constraint (e.g. clientX: 500, clientY: 500, two moves with .wait(50)
between, { force: true }), pointerup at that outside point (element
springs back into constraints, cursor position now outside the element),
.wait(500) for the spring to settle, then assert with .then() (NOT
.should()) that getComputedStyle(el).backgroundColor is
rgb(255, 0, 0) again. Model the pointer-event sequence on
packages/framer-motion/cypress/integration/drag-ref-constraints-absolute-scrolled.ts.
Note: in Cypress, hover start must be triggered explicitly
(.trigger("pointerenter", { force: true })) because synthetic moves don't
generate enter/leave. That is fine — the bug is about the end never firing.
Verify: run via the CLAUDE.md React 18 recipe → test FAILS on the final
assertion (background still green). If it passes, STOP — investigate whether
Cypress's trigger sequence fires a real pointerleave (try
cy.window().then() logging); if the bug can't be reproduced in 2–3
attempts, follow the CLAUDE.md "can't reproduce in test environment" rule.
In VisualElementDragControls.ts, in stop() (lines 267–282):
Before this.startAnimation(velocity), capture the release coordinates
in client space from finalEvent: const { clientX, clientY } = finalEvent.
Chain on the animation-settled promise. startAnimation already returns
the Promise.all(...) — change this.startAnimation(velocity) to use the
returned promise:
this.startAnimation(velocity).then(() => {
this.checkHover(clientX, clientY)
})
Add a private checkHover(clientX: number, clientY: number) method:
private checkHover(clientX: number, clientY: number) {
const { current } = this.visualElement
const { animationState } = this.visualElement
if (!current || !animationState || !this.visualElement.getProps().whileHover) return
const win = getContextWindow(this.visualElement)
const hit = win && win.document.elementFromPoint(clientX, clientY)
if (!hit || !current.contains(hit)) {
animationState.setActive("whileHover", false)
}
}
Match the codebase style: optional chaining, no var, small output size.
Guard everything — elementFromPoint must never throw the drag pipeline.
Note startAnimation only runs animations when constraints or
dragSnapToOrigin apply; when neither does, the element stays under the
cursor and checkHover is a harmless no-op (hit succeeds).
Verify: yarn build → exit 0; Step 1's Cypress spec now PASSES on
React 18.
Verify:
npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/drag" → all pass. (JSDOM document.elementFromPoint
exists but returns null-ish results; the guards make this safe. If a Jest
drag test fails on elementFromPoint, stub-guard with
win.document.elementFromPoint ? check.)drag.ts and drag-snap-animate-presence-exit.ts
on React 18 → pass (known-flaky family: re-run once before treating a
failure as real).plans/issues/README.md row updatedmotion-dom's hover() internals — report
instead; that changes public vanilla-API behavior.startAnimation — it
also feeds onDragTransitionEnd (line 511); do not change its timing.onHoverEnd callback is NOT fired on
programmatic reset, only the whileHover state is cleared. Also
dragSnapToOrigin mid-flight cancellations and layout-animation movement
share the symptom; follow-ups, not regressions.