From ba85e4cb19aaca8c2fbbd046623310f7ca861e65 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 22:08:36 +0100 Subject: [PATCH 1/2] feat(gestures): Add stopTapPropagation prop to prevent parent tap handlers from firing Fixes #2277. The documented workaround using onPointerDownCapture + stopPropagation is broken in React 17+ because React intercepts native events at the root. This adds a Motion-native mechanism using a WeakSet to track claimed pointerdown events, allowing child elements to prevent ancestor tap gesture handlers without affecting other listeners. Co-Authored-By: Claude Opus 4.5 --- .../src/gestures/__tests__/press.test.tsx | 124 ++++++++++++++++++ packages/framer-motion/src/gestures/press.ts | 7 +- .../src/motion/utils/valid-prop.ts | 1 + .../motion-dom/src/gestures/press/index.ts | 8 ++ packages/motion-dom/src/node/types.ts | 13 ++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 144f394e75..12feefec73 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,6 +767,130 @@ describe("press", () => { ]) }) + test("stopTapPropagation prevents parent onTap from firing", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + stopTapPropagation + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + }) + + test("without stopTapPropagation both parent and child onTap fire", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(1) + }) + + test("stopTapPropagation isolates whileTap to child only", () => { + const promise = new Promise(async (resolve) => { + const parentOpacityHistory: number[] = [] + const childOpacityHistory: number[] = [] + const parentOpacity = motionValue(0.5) + const childOpacity = motionValue(0.5) + const logOpacities = () => { + parentOpacityHistory.push(parentOpacity.get()) + childOpacityHistory.push(childOpacity.get()) + } + const Component = () => ( + + + + ) + + const { getByTestId } = render() + await nextFrame() + logOpacities() // both 0.5 + + pointerDown(getByTestId("child")) + await nextFrame() + logOpacities() // child 1, parent 0.5 + + pointerUp(getByTestId("child")) + await nextFrame() + logOpacities() // both 0.5 + + resolve({ parentOpacityHistory, childOpacityHistory }) + }) + + return expect(promise).resolves.toEqual({ + parentOpacityHistory: [0.5, 0.5, 0.5], + childOpacityHistory: [0.5, 1, 0.5], + }) + }) + + test("stopTapPropagation prevents all ancestor onTap handlers (three levels)", async () => { + const grandparentTap = jest.fn() + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + grandparentTap()}> + parentTap()}> + childTap()} + stopTapPropagation + /> + + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + expect(grandparentTap).toBeCalledTimes(0) + }) + test("ignore press event when button is disabled", async () => { const press = jest.fn() const Component = () => press()} disabled /> diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index e5d2650896..79585ae55d 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,6 +32,8 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return + const { globalTapTarget, stopTapPropagation } = this.node.props + this.unmount = press( current, (_element, startEvent) => { @@ -44,7 +46,10 @@ export class PressGesture extends Feature { success ? "End" : "Cancel" ) }, - { useGlobalTarget: this.node.props.globalTapTarget } + { + useGlobalTarget: globalTapTarget, + stopPropagation: stopTapPropagation, + } ) } diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 1c9a8ecf87..0a1875d16b 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,6 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", + "stopTapPropagation", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/src/gestures/press/index.ts b/packages/motion-dom/src/gestures/press/index.ts index ecc6f9805b..d82bc3565d 100644 --- a/packages/motion-dom/src/gestures/press/index.ts +++ b/packages/motion-dom/src/gestures/press/index.ts @@ -18,8 +18,11 @@ function isValidPressEvent(event: PointerEvent) { return isPrimaryPointer(event) && !isDragActive() } +const claimedPointerDownEvents = new WeakSet() + export interface PointerEventOptions extends EventOptions { useGlobalTarget?: boolean + stopPropagation?: boolean } /** @@ -55,9 +58,14 @@ export function press( const target = startEvent.currentTarget as Element if (!isValidPressEvent(startEvent)) return + if (claimedPointerDownEvents.has(startEvent)) return isPressing.add(target) + if (options.stopPropagation) { + claimedPointerDownEvents.add(startEvent) + } + const onPressEnd = onPressStart(target, startEvent) const onPointerEnd = (endEvent: PointerEvent, success: boolean) => { diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..7923acb3bd 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -538,6 +538,19 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean + + /** + * If `true`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. + * + * ```jsx + * + * + * + * ``` + */ + stopTapPropagation?: boolean } /** From d4c0bcd51a90a77be35c933ba984c286f6153ae4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 15:19:51 +0100 Subject: [PATCH 2/2] refactor(gestures): Rename stopTapPropagation to propagate={{ tap: false }} Avoids double negative naming and makes the API extensible for other gesture types (hover, drag, etc.). Also adds Playwright E2E tests verifying the stopPropagation option works at the motion-dom press() level. Co-Authored-By: Claude Opus 4.5 --- .../public/playwright/gestures/press.html | 19 ++++++++++ .../src/gestures/__tests__/press.test.tsx | 14 ++++---- packages/framer-motion/src/gestures/press.ts | 4 +-- .../src/motion/utils/valid-prop.ts | 2 +- packages/motion-dom/src/node/types.ts | 36 ++++++++++++------- tests/gestures/press.spec.ts | 24 +++++++++++++ 6 files changed, 76 insertions(+), 23 deletions(-) diff --git a/dev/html/public/playwright/gestures/press.html b/dev/html/public/playwright/gestures/press.html index 68ad250c98..ba5369916c 100644 --- a/dev/html/public/playwright/gestures/press.html +++ b/dev/html/public/playwright/gestures/press.html @@ -33,6 +33,10 @@
+
+
child
+
+ diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 12feefec73..77975ad6b5 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,7 +767,7 @@ describe("press", () => { ]) }) - test("stopTapPropagation prevents parent onTap from firing", async () => { + test("propagate={{ tap: false }} prevents parent onTap from firing", async () => { const parentTap = jest.fn() const childTap = jest.fn() const Component = () => ( @@ -775,7 +775,7 @@ describe("press", () => { childTap()} - stopTapPropagation + propagate={{ tap: false }} /> ) @@ -791,7 +791,7 @@ describe("press", () => { expect(parentTap).toBeCalledTimes(0) }) - test("without stopTapPropagation both parent and child onTap fire", async () => { + test("without propagate both parent and child onTap fire", async () => { const parentTap = jest.fn() const childTap = jest.fn() const Component = () => ( @@ -814,7 +814,7 @@ describe("press", () => { expect(parentTap).toBeCalledTimes(1) }) - test("stopTapPropagation isolates whileTap to child only", () => { + test("propagate={{ tap: false }} isolates whileTap to child only", () => { const promise = new Promise(async (resolve) => { const parentOpacityHistory: number[] = [] const childOpacityHistory: number[] = [] @@ -837,7 +837,7 @@ describe("press", () => { transition={{ type: false }} whileTap={{ opacity: 1 }} style={{ opacity: childOpacity }} - stopTapPropagation + propagate={{ tap: false }} />
) @@ -863,7 +863,7 @@ describe("press", () => { }) }) - test("stopTapPropagation prevents all ancestor onTap handlers (three levels)", async () => { + test("propagate={{ tap: false }} prevents all ancestor onTap handlers (three levels)", async () => { const grandparentTap = jest.fn() const parentTap = jest.fn() const childTap = jest.fn() @@ -873,7 +873,7 @@ describe("press", () => { childTap()} - stopTapPropagation + propagate={{ tap: false }} />
diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index 79585ae55d..f75f7b8a2f 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,7 +32,7 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return - const { globalTapTarget, stopTapPropagation } = this.node.props + const { globalTapTarget, propagate } = this.node.props this.unmount = press( current, @@ -48,7 +48,7 @@ export class PressGesture extends Feature { }, { useGlobalTarget: globalTapTarget, - stopPropagation: stopTapPropagation, + stopPropagation: propagate?.tap === false, } ) } diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 0a1875d16b..515942d845 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,7 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", - "stopTapPropagation", + "propagate", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index 7923acb3bd..b7d49751fe 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -539,18 +539,6 @@ export interface MotionNodeTapHandlers { */ globalTapTarget?: boolean - /** - * If `true`, this element's tap gesture will prevent any parent - * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) - * from firing. - * - * ```jsx - * - * - * - * ``` - */ - stopTapPropagation?: boolean } /** @@ -1056,6 +1044,15 @@ export interface MotionNodeAdvancedOptions { "data-framer-appear-id"?: string } +export interface PropagateOptions { + /** + * If `false`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. Defaults to `true`. + */ + tap?: boolean +} + export interface MotionNodeOptions extends MotionNodeAnimationOptions, MotionNodeEventOptions, @@ -1067,4 +1064,17 @@ export interface MotionNodeOptions MotionNodeDragHandlers, MotionNodeDraggableOptions, MotionNodeLayoutOptions, - MotionNodeAdvancedOptions {} + MotionNodeAdvancedOptions { + /** + * Controls whether gesture events propagate to parent motion components. + * By default all gestures propagate. Set individual gestures to `false` + * to prevent parent handlers from firing. + * + * ```jsx + * + * + * + * ``` + */ + propagate?: PropagateOptions +} diff --git a/tests/gestures/press.spec.ts b/tests/gestures/press.spec.ts index 25a8b2b98d..b54e042eb9 100644 --- a/tests/gestures/press.spec.ts +++ b/tests/gestures/press.spec.ts @@ -245,6 +245,30 @@ test.describe("press events", () => { // await expect(windowOutput).toHaveValue("cancel") }) + test("stopPropagation prevents parent press from firing", async ({ + page, + }) => { + const child = page.locator("#propagate-child") + const output = page.locator("#propagate-output") + + // Press child - only child handlers should fire + await child.dispatchEvent("pointerdown", pointerOptions) + await child.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("child-start,child-end,") + }) + + test("parent press fires when clicking outside child", async ({ + page, + }) => { + const parent = page.locator("#propagate-parent") + const output = page.locator("#propagate-output") + + // Press parent directly - parent handlers should fire + await parent.dispatchEvent("pointerdown", pointerOptions) + await parent.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("parent-start,parent-end,") + }) + test("nested click handlers", async ({ page }) => { const button = page.locator("#press-click-button") const box = await button.boundingBox()