diff --git a/CLAUDE.md b/CLAUDE.md
index a7987720b3..fe6d39bc11 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -73,6 +73,8 @@ motion (public API)
## Writing Tests
+**IMPORTANT: Always write a failing test FIRST before implementing any bug fix or feature.** This ensures the issue is reproducible and the fix is verified. For UI interaction bugs (like gesture handling), prefer E2E tests using Playwright or Cypress.
+
When waiting for the next frame in async tests:
```javascript
diff --git a/dev/react/src/tests/drag-input-propagation.tsx b/dev/react/src/tests/drag-input-propagation.tsx
new file mode 100644
index 0000000000..61fb5e0f45
--- /dev/null
+++ b/dev/react/src/tests/drag-input-propagation.tsx
@@ -0,0 +1,112 @@
+import { motion } from "framer-motion"
+
+/**
+ * Test page for issue #1674: Interactive elements inside draggable elements
+ * should not trigger drag when clicked/interacted with.
+ */
+export const App = () => {
+ return (
+
+ )
+}
diff --git a/packages/framer-motion/cypress/integration/drag-input-propagation.ts b/packages/framer-motion/cypress/integration/drag-input-propagation.ts
new file mode 100644
index 0000000000..0fafbb74e3
--- /dev/null
+++ b/packages/framer-motion/cypress/integration/drag-input-propagation.ts
@@ -0,0 +1,222 @@
+/**
+ * Tests for issue #1674: Interactive elements inside draggable elements should not trigger drag
+ * https://github.com/motiondivision/motion/issues/1674
+ */
+describe("Drag Input Propagation", () => {
+ it("Should not drag when clicking and dragging on an input inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ // Initial position is at padding: 100
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the input
+ cy.get("[data-testid='input']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ // Element should still be at its initial position
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a textarea inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the textarea
+ cy.get("[data-testid='textarea']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ // Element should still be at its initial position
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a button inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the button
+ cy.get("[data-testid='button']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a link inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the link
+ cy.get("[data-testid='link']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a select inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the select
+ cy.get("[data-testid='select']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a checkbox inside a label inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the checkbox (nested inside label)
+ cy.get("[data-testid='checkbox']")
+ .trigger("pointerdown", 2, 2)
+ .trigger("pointermove", 5, 5)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should not drag when clicking and dragging on a contenteditable element inside draggable", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+
+ // Attempt to drag by clicking on the contenteditable element
+ cy.get("[data-testid='contenteditable']")
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+
+ // Verify the draggable element did NOT move
+ cy.get("[data-testid='draggable']").should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ })
+
+ it("Should still drag when clicking on the draggable area outside interactive elements", () => {
+ cy.visit("?test=drag-input-propagation")
+ .wait(200)
+ .get("[data-testid='draggable']")
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ expect(left).to.equal(100)
+ expect(top).to.equal(100)
+ })
+ // Click on edge of draggable, not on interactive elements (at coordinates 5,5 which is top-left corner)
+ .trigger("pointerdown", 5, 5)
+ .trigger("pointermove", 10, 10)
+ .wait(50)
+ .trigger("pointermove", 200, 200, { force: true })
+ .wait(50)
+ .trigger("pointerup", { force: true })
+ .should(($draggable) => {
+ const { left, top } = $draggable[0].getBoundingClientRect()
+ // Element should have moved - the exact position depends on gesture calculation
+ // but should NOT be at original position of 100,100
+ expect(left).to.be.greaterThan(200)
+ expect(top).to.be.greaterThan(200)
+ })
+ })
+})
diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts
index 848d0285fb..6ec6c5546a 100644
--- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts
+++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts
@@ -1,4 +1,5 @@
import {
+ isElementKeyboardAccessible,
PanInfo,
ResolvedConstraints,
Transition,
@@ -643,7 +644,13 @@ export class VisualElementDragControls {
"pointerdown",
(event) => {
const { drag, dragListener = true } = this.getProps()
- drag && dragListener && this.start(event)
+ if (
+ drag &&
+ dragListener &&
+ !isElementKeyboardAccessible(event.target as Element)
+ ) {
+ this.start(event)
+ }
}
)
diff --git a/packages/motion-dom/src/gestures/press/utils/is-keyboard-accessible.ts b/packages/motion-dom/src/gestures/press/utils/is-keyboard-accessible.ts
index 1e38087085..a22b9be82d 100644
--- a/packages/motion-dom/src/gestures/press/utils/is-keyboard-accessible.ts
+++ b/packages/motion-dom/src/gestures/press/utils/is-keyboard-accessible.ts
@@ -1,4 +1,4 @@
-const focusableElements = new Set([
+const interactiveElements = new Set([
"BUTTON",
"INPUT",
"SELECT",
@@ -6,9 +6,17 @@ const focusableElements = new Set([
"A",
])
+/**
+ * Checks if an element is an interactive form element that should prevent
+ * drag gestures from starting when clicked.
+ *
+ * This specifically targets form controls, buttons, and links - not just any
+ * element with tabIndex, since motion elements with tap handlers automatically
+ * get tabIndex=0 for keyboard accessibility.
+ */
export function isElementKeyboardAccessible(element: Element) {
return (
- focusableElements.has(element.tagName) ||
- (element as HTMLElement).tabIndex !== -1
+ interactiveElements.has(element.tagName) ||
+ (element as HTMLElement).isContentEditable === true
)
}
diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts
index a138e4c63f..571e450a9b 100644
--- a/packages/motion-dom/src/index.ts
+++ b/packages/motion-dom/src/index.ts
@@ -56,6 +56,7 @@ export * from "./gestures/hover"
export * from "./gestures/pan/types"
export * from "./gestures/press"
export * from "./gestures/press/types"
+export * from "./gestures/press/utils/is-keyboard-accessible"
export * from "./gestures/types"
export * from "./gestures/utils/is-node-or-child"
export * from "./gestures/utils/is-primary-pointer"