diff --git a/.yarn/cache/resolve-patch-99efc4a639-064d09c180.zip b/.yarn/cache/resolve-patch-99efc4a639-064d09c180.zip new file mode 100644 index 0000000000..6fad4662cd Binary files /dev/null and b/.yarn/cache/resolve-patch-99efc4a639-064d09c180.zip differ diff --git a/.yarn/cache/resolve-patch-d29ff41c85-8aac1e4e46.zip b/.yarn/cache/resolve-patch-d29ff41c85-8aac1e4e46.zip new file mode 100644 index 0000000000..227abf6133 Binary files /dev/null and b/.yarn/cache/resolve-patch-d29ff41c85-8aac1e4e46.zip differ diff --git a/.yarn/cache/typescript-patch-f72ba92d4d-3a62fe90aa.zip b/.yarn/cache/typescript-patch-f72ba92d4d-3a62fe90aa.zip new file mode 100644 index 0000000000..84160597ce Binary files /dev/null and b/.yarn/cache/typescript-patch-f72ba92d4d-3a62fe90aa.zip differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aeb8f2dcb..c686561f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.28.0] 2026-01-20 + +### Added + +- `useFollowValue` and `followValue`: `useSpring`-style motion values that can accept any transition. + +### Fixed + +- Fix "multiple keyframe" error when using spring animations as default transitions for animation sequences. + +## [12.27.5] 2026-01-20 + +### Fixed + +- Ensure pen gesture is correctly cleaned up on drag cancel. +- Fix edge case where `DocumentProjectionNode`'s attached element was `null`. + +## [12.27.4] 2026-01-20 + +### Added + +- `AnimatePresence`: `anchorY` for vertically positioning popped children. + +### Fixed + +- Fixed path drawing animations in zoomed Safari contexts by switching to unitless values. + +## [12.27.3] 2026-01-20 + +### Fixed + +- Ensure drag gestures trigger from keyboard-accessible elements (`button`, `textarea` etc) if these elements have `drag` applied directly. + ## [12.27.2] 2026-01-20 ### Fixed diff --git a/dev/html/package.json b/dev/html/package.json index f0e64c9cf8..c563403b50 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.2", + "version": "12.28.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.2", - "motion": "^12.27.2", - "motion-dom": "^12.27.2", + "framer-motion": "^12.28.0", + "motion": "^12.28.0", + "motion-dom": "^12.28.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 0b1543592d..56ecd07628 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.2", + "version": "12.28.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.2", + "motion": "^12.28.0", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 2a63004b72..48bed74ebf 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.27.2", + "version": "12.28.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.2", + "motion": "^12.28.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 5b09dddd50..2b4ce9f0bb 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.2", + "version": "12.28.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.2", + "framer-motion": "^12.28.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index fa7cad9a23..a4f79db0b5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.2", + "version": "12.28.0", "packages": [ "packages/*", "dev/*" diff --git a/package.json b/package.json index 08ca89de90..fef50eb2c6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev-server": "turbo run dev-server", "lint": "turbo run lint", "test": "turbo run test", - "test-playwright": "yarn playwright test", + "test-playwright": "turbo run build && yarn playwright test", "test-ci": "turbo run test-ci --no-cache", "measure": "turbo run measure --force && node dev/inc/bundlesize.mjs", "version": "yarn install && git stage yarn.lock", diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index ede6381074..5597a3e3a6 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","interrupt-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-app-store.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file +["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file diff --git a/packages/framer-motion/cypress/integration/drag-nested.ts b/packages/framer-motion/cypress/integration/drag-nested.ts index 6d4dfd733e..d619134f2f 100644 --- a/packages/framer-motion/cypress/integration/drag-nested.ts +++ b/packages/framer-motion/cypress/integration/drag-nested.ts @@ -19,7 +19,7 @@ function testNestedDrag(parentLayout: boolean, childLayout: boolean) { if (childLayout) url += `&childLayout=true` cy.visit(url) - .wait(50) + .wait(200) .get("#parent") .should(([$parent]: any) => { expectBbox($parent, { @@ -128,7 +128,7 @@ function testNestedDrag(parentLayout: boolean, childLayout: boolean) { }) .get("#parent") .trigger("pointerdown", 5, 5) - .wait(20) + .wait(50) .trigger("pointermove", 10, 10) // Gesture will start from first move past threshold .wait(50) .trigger("pointermove", 50, 50) @@ -174,6 +174,7 @@ function testNestedDragConstraints( if (childLayout) url += `&childLayout=true` cy.visit(url) + .wait(200) .get("#parent") .trigger("pointerdown", 40, 40) .wait(50) @@ -280,6 +281,7 @@ function testNestedDragConstraintsAndAnimation( if (parentLayout) url += `&parentLayout=true` if (childLayout) url += `&childLayout=true` cy.visit(url) + .wait(200) .get("#parent") .trigger("pointerdown", 5, 10) .wait(50) @@ -367,7 +369,7 @@ function testAlternateAxes(parentLayout: boolean, childLayout: boolean) { if (childLayout) url += `&childLayout=true` return cy .visit(url) - .wait(50) + .wait(200) .get("#child") .trigger("pointerdown", 5, 5, { force: true }) .wait(50) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 09276d376f..63b0909fd1 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.2", + "version": "12.28.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.27.2", + "motion-dom": "^12.28.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index 1b072c7672..75d031dd87 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -641,6 +641,31 @@ describe("createAnimationsFromSequence", () => { expect(times).toEqual([0, 0.45454545454545453, 0.45454545454545453, 1]) }) + test("Does not include type: spring in transition when spring is converted to easing via defaultTransition", () => { + const animations = createAnimationsFromSequence( + [ + [a, { x: 0 }, { duration: 0 }], + [a, { x: 1.12 }], + [a, { x: 0.98 }, { at: "<+0.15" }], + [a, { x: 1 }, { at: "<+0.35" }], + ], + { defaultTransition: { type: "spring", stiffness: 72, damping: 10 } }, + undefined, + { spring } + ) + + const { transition } = animations.get(a)! + + // The spring should be converted to easing functions, not kept as type: "spring" + expect(transition.x.type).toBeUndefined() + + // Verify the easing functions are present + expect(Array.isArray(transition.x.ease)).toBe(true) + const easeArray = transition.x.ease as Easing[] + // At least some of the easings should be spring-converted functions + expect(easeArray.some((e) => typeof e === "function")).toBe(true) + }) + test("It correctly repeats keyframes once", () => { const animations = createAnimationsFromSequence( [[a, { x: [0, 100] }, { duration: 1, repeat: 1, ease: "linear" }]], diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 7707723f43..07ead3c85b 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -109,7 +109,7 @@ export function createAnimationsFromSequence( const { delay = 0, times = defaultOffset(valueKeyframesAsList), - type = "keyframes", + type = defaultTransition.type || "keyframes", repeat, repeatType, repeatDelay = 0, @@ -151,7 +151,10 @@ export function createAnimationsFromSequence( absoluteDelta = Math.abs(delta) } - const springTransition = { ...remainingTransition } + const springTransition = { + ...defaultTransition, + ...remainingTransition, + } if (duration !== undefined) { springTransition.duration = secondsToMilliseconds(duration) } @@ -368,8 +371,17 @@ export function createAnimationsFromSequence( const definition = animationDefinitions.get(element)! definition.keyframes[key] = keyframes + + /** + * Exclude `type` from defaultTransition since springs have been + * converted to duration-based easing functions in resolveValueSequence. + * Including `type: "spring"` would cause JSAnimation to error when + * the merged keyframes array has more than 2 keyframes. + */ + const { type: _type, ...remainingDefaultTransition } = + defaultTransition definition.transition[key] = { - ...defaultTransition, + ...remainingDefaultTransition, duration: totalDuration, ease: valueEasing, times: valueOffset, diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index 6e2cfabe6a..ba10491929 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -13,12 +13,14 @@ interface Size { top: number left: number right: number + bottom: number } interface Props { children: React.ReactElement isPresent: boolean anchorX?: "left" | "right" + anchorY?: "top" | "bottom" root?: HTMLElement | ShadowRoot } @@ -39,6 +41,9 @@ class PopChildMeasure extends React.Component { const parentWidth = isHTMLElement(parent) ? parent.offsetWidth || 0 : 0 + const parentHeight = isHTMLElement(parent) + ? parent.offsetHeight || 0 + : 0 const size = this.props.sizeRef.current! size.height = element.offsetHeight || 0 @@ -46,6 +51,7 @@ class PopChildMeasure extends React.Component { size.top = element.offsetTop size.left = element.offsetLeft size.right = parentWidth - size.width - size.left + size.bottom = parentHeight - size.height - size.top } return null @@ -61,7 +67,7 @@ class PopChildMeasure extends React.Component { } } -export function PopChild({ children, isPresent, anchorX, root }: Props) { +export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) { const id = useId() const ref = useRef(null) const size = useRef({ @@ -70,6 +76,7 @@ export function PopChild({ children, isPresent, anchorX, root }: Props) { top: 0, left: 0, right: 0, + bottom: 0, }) const { nonce } = useContext(MotionConfigContext) /** @@ -91,10 +98,11 @@ export function PopChild({ children, isPresent, anchorX, root }: Props) { * styles set via the style prop. */ useInsertionEffect(() => { - const { width, height, top, left, right } = size.current + const { width, height, top, left, right, bottom } = size.current if (isPresent || !ref.current || !width || !height) return const x = anchorX === "left" ? `left: ${left}` : `right: ${right}` + const y = anchorY === "bottom" ? `bottom: ${bottom}` : `top: ${top}` ref.current.dataset.motionPopId = id @@ -111,7 +119,7 @@ export function PopChild({ children, isPresent, anchorX, root }: Props) { width: ${width}px !important; height: ${height}px !important; ${x}px !important; - top: ${top}px !important; + ${y}px !important; } `) } diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 671574df90..5b933bd86f 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -19,6 +19,7 @@ interface PresenceChildProps { presenceAffectsLayout: boolean mode: "sync" | "popLayout" | "wait" anchorX?: "left" | "right" + anchorY?: "top" | "bottom" root?: HTMLElement | ShadowRoot } @@ -31,6 +32,7 @@ export const PresenceChild = ({ presenceAffectsLayout, mode, anchorX, + anchorY, root }: PresenceChildProps) => { const presenceChildren = useConstant(newChildrenMap) @@ -86,7 +88,7 @@ export const PresenceChild = ({ if (mode === "popLayout") { children = ( - + {children} ) diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index 7972e00eae..00071173fe 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -660,6 +660,63 @@ describe("AnimatePresence", () => { const result = await promise return expect(result).toHaveAttribute("data-id", "2") }) + + test("popLayout mode with anchorY='bottom' preserves bottom positioning", async () => { + const ref = createRef() + + const Component = ({ isVisible }: { isVisible: boolean }) => { + return ( +
+ + {isVisible && ( + + )} + +
+ ) + } + + const { rerender } = render() + rerender() + + await nextFrame() + + // Get initial position (should be at bottom) + const initialBottom = + ref.current!.parentElement!.offsetHeight - + ref.current!.offsetTop - + ref.current!.offsetHeight + + await act(async () => { + rerender() + }) + + await nextFrame() + + // After popLayout, element should still be at the same bottom position + // Check that the injected style uses bottom positioning + const computedStyle = window.getComputedStyle(ref.current!) + expect(computedStyle.position).toBe("absolute") + + // The bottom position should be preserved (approximately 0) + expect(initialBottom).toBeLessThanOrEqual(1) + }) }) describe("AnimatePresence with custom components", () => { diff --git a/packages/framer-motion/src/components/AnimatePresence/index.tsx b/packages/framer-motion/src/components/AnimatePresence/index.tsx index da1fbf11d5..b00d4071be 100644 --- a/packages/framer-motion/src/components/AnimatePresence/index.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/index.tsx @@ -52,6 +52,7 @@ export const AnimatePresence = ({ mode = "sync", propagate = false, anchorX = "left", + anchorY = "top", root }: React.PropsWithChildren) => { const [isParentPresent, safeToRemove] = usePresence(propagate) @@ -226,6 +227,7 @@ export const AnimatePresence = ({ root={root} onExitComplete={isPresent ? undefined : onExit} anchorX={anchorX} + anchorY={anchorY} > {child} diff --git a/packages/framer-motion/src/components/AnimatePresence/types.ts b/packages/framer-motion/src/components/AnimatePresence/types.ts index 040f4cf14a..b51fc6023a 100644 --- a/packages/framer-motion/src/components/AnimatePresence/types.ts +++ b/packages/framer-motion/src/components/AnimatePresence/types.ts @@ -75,4 +75,11 @@ export interface AnimatePresenceProps { * when using `mode="popLayout"`. */ anchorX?: "left" | "right" + + /** + * Internal. Set whether to anchor the y position of the exiting element to the top or bottom + * when using `mode="popLayout"`. Use `"bottom"` for elements originally positioned with + * `bottom: 0` to prevent them from shifting during exit animations. + */ + anchorY?: "top" | "bottom" } diff --git a/packages/framer-motion/src/components/AnimatePresence/use-presence.ts b/packages/framer-motion/src/components/AnimatePresence/use-presence.ts index 7b1e98fd73..bd15364098 100644 --- a/packages/framer-motion/src/components/AnimatePresence/use-presence.ts +++ b/packages/framer-motion/src/components/AnimatePresence/use-presence.ts @@ -32,8 +32,8 @@ type NotPresent = [false, SafeToRemove] * } * ``` * - * If `isPresent` is `false`, it means that a component has been removed the tree, but - * `AnimatePresence` won't really remove it until `safeToRemove` has been called. + * If `isPresent` is `false`, it means that a component has been removed from the tree, + * but `AnimatePresence` won't really remove it until `safeToRemove` has been called. * * @public */ diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 03d3b1de6e..5a2001996b 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -299,8 +299,7 @@ export class VisualElementDragControls { projection.isAnimationBlocked = false } - this.panSession && this.panSession.end() - this.panSession = undefined + this.endPanSession() const { dragPropagation } = this.getProps() @@ -312,6 +311,17 @@ export class VisualElementDragControls { animationState && animationState.setActive("whileDrag", false) } + /** + * Clean up the pan session without modifying other drag state. + * This is used during unmount to ensure event listeners are removed + * without affecting projection animations or drag locks. + * @internal + */ + endPanSession() { + this.panSession && this.panSession.end() + this.panSession = undefined + } + private updateAxis(axis: DragDirection, _point: Point, offset?: Point) { const { drag } = this.getProps() @@ -642,11 +652,18 @@ export class VisualElementDragControls { "pointerdown", (event) => { const { drag, dragListener = true } = this.getProps() - if ( - drag && - dragListener && - !isElementKeyboardAccessible(event.target as Element) - ) { + const target = event.target as Element + + /** + * Only block drag if clicking on a keyboard-accessible child element. + * If the draggable element itself is keyboard-accessible (e.g., motion.button), + * dragging should still work when clicking directly on it. + */ + const isClickingKeyboardAccessibleChild = + target !== element && + isElementKeyboardAccessible(target) + + if (drag && dragListener && !isClickingKeyboardAccessibleChild) { this.start(event) } } diff --git a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx index cbae823c7d..f2607d493c 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -6,7 +6,7 @@ import { MotionValue, useWillChange, } from "../../../" -import { pointerDown, render } from "../../../jest.setup" +import { pointerDown, pointerMove, render } from "../../../jest.setup" import { WillChangeMotionValue } from "../../../value/use-will-change/WillChangeMotionValue" import { nextFrame } from "../../__tests__/utils" import { deferred, drag, dragFrame, MockDrag, Point, sleep } from "./utils" @@ -938,4 +938,184 @@ describe("dragging", () => { "transform: translateX(105px) translateY(0px)" ) }) + + test("cleans up pan session event listeners when unmounting during active gesture", async () => { + const x = motionValue(0) + const y = motionValue(0) + const Component = () => ( + + + + ) + + const { getByTestId, rerender, unmount } = render() + rerender() + + // Start drag - this creates a PanSession with window event listeners + const element = getByTestId("draggable") + pointerDown(element) + + await nextFrame() + + // Track if window listeners exist by counting pointermove handlers + // We'll fire a pointermove and check if x changed (it shouldn't after unmount) + const xBefore = x.get() + + // Unmount while gesture is active + unmount() + + await nextFrame() + + // Fire pointermove on window - should not affect anything since listeners should be cleaned up + // If listeners weren't cleaned up, this could cause errors or memory leaks + pointerMove(document.body) + + await nextFrame() + + // x should still be the same since component unmounted + expect(x.get()).toBe(xBefore) + }) +}) + +describe("keyboard accessible elements", () => { + test("drag gesture starts on a motion.button with drag prop", async () => { + const onDragStart = jest.fn() + const x = motionValue(0) + const Component = () => ( + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag(getByTestId("draggable-button")).to(100, 100) + pointer.end() + + await nextFrame() + + expect(onDragStart).toBeCalledTimes(1) + expect(x.get()).toBeGreaterThanOrEqual(100) + }) + + test("drag gesture does not start when clicking a child button", async () => { + const onDragStart = jest.fn() + const x = motionValue(0) + const Component = () => ( + + + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag( + getByTestId("draggable"), + getByTestId("child-button") + ).to(100, 100) + pointer.end() + + await nextFrame() + + expect(onDragStart).toBeCalledTimes(0) + expect(x.get()).toBe(0) + }) + + test("drag gesture starts on a motion.input with drag prop", async () => { + const onDragStart = jest.fn() + const x = motionValue(0) + const Component = () => ( + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag(getByTestId("draggable-input")).to(100, 100) + pointer.end() + + await nextFrame() + + expect(onDragStart).toBeCalledTimes(1) + expect(x.get()).toBeGreaterThanOrEqual(100) + }) + + test("drag gesture starts on a motion.a with drag prop", async () => { + const onDragStart = jest.fn() + const x = motionValue(0) + const Component = () => ( + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag(getByTestId("draggable-link")).to(100, 100) + pointer.end() + + await nextFrame() + + expect(onDragStart).toBeCalledTimes(1) + expect(x.get()).toBeGreaterThanOrEqual(100) + }) + + test("drag gesture does not start when clicking a child input", async () => { + const onDragStart = jest.fn() + const x = motionValue(0) + const Component = () => ( + + + + + + ) + + const { getByTestId, rerender } = render() + rerender() + + const pointer = await drag( + getByTestId("draggable"), + getByTestId("child-input") + ).to(100, 100) + pointer.end() + + await nextFrame() + + expect(onDragStart).toBeCalledTimes(0) + expect(x.get()).toBe(0) + }) }) diff --git a/packages/framer-motion/src/gestures/drag/index.ts b/packages/framer-motion/src/gestures/drag/index.ts index be540e5d6c..a2bafe2428 100644 --- a/packages/framer-motion/src/gestures/drag/index.ts +++ b/packages/framer-motion/src/gestures/drag/index.ts @@ -40,5 +40,11 @@ export class DragGesture extends Feature { unmount() { this.removeGroupControls() this.removeListeners() + /** + * Only clean up the pan session if one exists. We use endPanSession() + * instead of cancel() because cancel() also modifies projection animation + * state and drag locks, which could interfere with nested drag scenarios. + */ + this.controls.endPanSession() } } diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index 422df09ba5..ae2ca1a5f6 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -49,6 +49,8 @@ export { useViewportScroll } from "./value/scroll/use-viewport-scroll" export { useMotionTemplate } from "./value/use-motion-template" export { useMotionValue } from "./value/use-motion-value" export { useScroll, UseScrollOptions } from "./value/use-scroll" +export { useFollowValue } from "./value/use-follow-value" +export type { FollowValueOptions } from "motion-dom" export { useSpring } from "./value/use-spring" export { useTime } from "./value/use-time" export { useTransform } from "./value/use-transform" diff --git a/packages/framer-motion/src/jest.setup.tsx b/packages/framer-motion/src/jest.setup.tsx index 22f73d0b0a..7e46f50497 100644 --- a/packages/framer-motion/src/jest.setup.tsx +++ b/packages/framer-motion/src/jest.setup.tsx @@ -83,6 +83,13 @@ export const pointerMove = (element: Element) => new PointerEventFake("pointermove", { isPrimary: true }) ) }) +export const pointerCancel = (element: Element) => + act(() => { + fireEvent.pointerCancel( + element, + new PointerEventFake("pointercancel", { isPrimary: true }) + ) + }) export const focus = (element: HTMLElement, testId: string) => act(() => { getByTestId(element, testId).focus() diff --git a/packages/framer-motion/src/motion/__tests__/ssr.test.tsx b/packages/framer-motion/src/motion/__tests__/ssr.test.tsx index 1c5195fa2d..4d755991f6 100644 --- a/packages/framer-motion/src/motion/__tests__/ssr.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/ssr.test.tsx @@ -185,7 +185,7 @@ function runTests(render: (components: any) => string) { ) expect(circle).toBe( - '' + '' ) const rect = render( diff --git a/packages/framer-motion/src/motion/__tests__/svg-path.test.tsx b/packages/framer-motion/src/motion/__tests__/svg-path.test.tsx index f2da907a22..21f272d11e 100644 --- a/packages/framer-motion/src/motion/__tests__/svg-path.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/svg-path.test.tsx @@ -18,8 +18,8 @@ describe("SVG path", () => { rerender() }) - expect(element).toHaveAttribute("stroke-dashoffset", "0px") - expect(element).toHaveAttribute("stroke-dasharray", "1px 1px") + expect(element).toHaveAttribute("stroke-dashoffset", "0") + expect(element).toHaveAttribute("stroke-dasharray", "1 1") expect(element).toHaveAttribute("pathLength", "1") }) }) diff --git a/packages/framer-motion/src/render/svg/__tests__/use-props.test.ts b/packages/framer-motion/src/render/svg/__tests__/use-props.test.ts index 3ed6a60812..c6490f9f78 100644 --- a/packages/framer-motion/src/render/svg/__tests__/use-props.test.ts +++ b/packages/framer-motion/src/render/svg/__tests__/use-props.test.ts @@ -54,10 +54,11 @@ describe("SVG useProps", () => { ) ) + // Uses unitless values to avoid Safari zoom bug expect(result.current).toStrictEqual({ pathLength: 1, - strokeDasharray: "0.5px 1px", - strokeDashoffset: "0px", + strokeDasharray: "0.5 1", + strokeDashoffset: "0", style: {}, }) }) diff --git a/packages/framer-motion/src/render/svg/utils/__tests__/path.test.ts b/packages/framer-motion/src/render/svg/utils/__tests__/path.test.ts index 09ef5a8711..bcbde43021 100644 --- a/packages/framer-motion/src/render/svg/utils/__tests__/path.test.ts +++ b/packages/framer-motion/src/render/svg/utils/__tests__/path.test.ts @@ -4,13 +4,14 @@ import "../../../../jest.setup" describe("buildSVGPath", () => { it("correctly generates SVG path props", () => { const attrs: { - ["stroke-dashoffset"]?: number - ["stroke-dasharray"]?: number + ["stroke-dashoffset"]?: string + ["stroke-dasharray"]?: string } = {} buildSVGPath(attrs, 0.5, 0.25, 0.25) - expect(attrs["stroke-dashoffset"]).toBe("-0.25px") - expect(attrs["stroke-dasharray"]).toBe("0.5px 0.25px") + // Uses unitless values to avoid Safari zoom bug + expect(attrs["stroke-dashoffset"]).toBe("-0.25") + expect(attrs["stroke-dasharray"]).toBe("0.5 0.25") }) }) diff --git a/packages/framer-motion/src/value/__tests__/use-follow-value.test.tsx b/packages/framer-motion/src/value/__tests__/use-follow-value.test.tsx new file mode 100644 index 0000000000..35e7dd9980 --- /dev/null +++ b/packages/framer-motion/src/value/__tests__/use-follow-value.test.tsx @@ -0,0 +1,339 @@ +import { motionValue, MotionValue } from "motion-dom" +import { useEffect } from "react" +import { motion, useMotionValueEvent } from "../../" +import { syncDriver } from "../../animation/animators/__tests__/utils" +import { render } from "../../jest.setup" +import { useMotionValue } from "../use-motion-value" +import { useFollowValue } from "../use-follow-value" + +describe("useFollowValue types", () => { + test("can create a motion value from a number", async () => { + const Component = () => { + const x = useFollowValue(0) + expect(x.get()).toBe(0) + return null + } + render() + }) + + test("can create a motion value from a string with a unit", async () => { + const Component = () => { + const x = useFollowValue("0%") + expect(x.get()).toBe("0%") + return null + } + render() + }) + + test("can create a motion value from a number motion value", async () => { + const Component = () => { + const source = motionValue(0) + const x = useFollowValue(source) + expect(x.get()).toBe(0) + return null + } + render() + }) + + test("can create a motion value from a string motion value with a unit", async () => { + const Component = () => { + const source = motionValue("0%") + const x = useFollowValue(source) + expect(x.get()).toBe("0%") + return null + } + render() + }) +}) + +const runAnimatedValueTests = (unit?: string | undefined) => { + const createValue = (num: number) => { + if (unit) { + return `${num}${unit}` as unknown as number + } + return num as number + } + + const parseTestValue = (val: string | number): number => + typeof val === "string" ? parseFloat(val) : val + + const formatOutput = (num: number) => { + if (unit) { + return `${Math.round(num)}${unit}` + } + return Math.round(num) + } + + describe(`useFollowValue ${unit ? `with ${unit}` : "with numbers"}`, () => { + test("can create a motion value from a number (default spring)", async () => { + const promise = new Promise((resolve) => { + const Component = () => { + const x = useMotionValue(createValue(0)) + const animated = useFollowValue(x) + + useEffect(() => { + animated.on("change", (v) => resolve(v)) + x.set(createValue(100)) + }) + + return null + } + + const { rerender } = render() + rerender() + }) + + const resolved = await promise + + expect(resolved).not.toBe(createValue(0)) + expect(resolved).not.toBe(createValue(100)) + }) + + test("can create a MotionValue that responds to changes from another MotionValue", async () => { + const promise = new Promise((resolve) => { + const Component = () => { + const x = useMotionValue(createValue(0)) + const y = useFollowValue(x) + + useEffect(() => { + y.on("change", (v) => resolve(v)) + x.set(createValue(100)) + }) + + return null + } + + const { rerender } = render() + rerender() + }) + + const resolved = await promise + + expect(resolved).not.toBe(createValue(0)) + expect(resolved).not.toBe(createValue(100)) + }) + + test("creates a spring animation when type is spring", async () => { + const promise = new Promise>((resolve) => { + const output: Array = [] + const Component = () => { + const x = useMotionValue(createValue(0)) + const y = useFollowValue(x, { + type: "spring", + driver: syncDriver(10), + } as any) + + useEffect(() => { + return y.on("change", (v) => { + if (output.length >= 10) { + resolve(output) + } else { + output.push(formatOutput(parseTestValue(v))) + } + }) + }) + + useEffect(() => { + x.set(createValue(100)) + }, []) + + return null + } + + const { rerender } = render() + rerender() + }) + + const resolved = await promise + + const testNear = ( + value: string | number, + expected: number, + deviation = 2 + ) => { + const numValue = parseTestValue(value) + expect( + numValue >= expected - deviation && + numValue <= expected + deviation + ).toBe(true) + } + + testNear(resolved[0], 0) + testNear(resolved[4], 10) + testNear(resolved[8], 30) + }) + + test("will not animate if immediate=true (jump)", async () => { + const promise = new Promise((resolve) => { + const output: Array = [] + const Component = () => { + const x = useMotionValue(createValue(0)) + const y = useFollowValue(x, { + driver: syncDriver(10), + } as any) + + useEffect(() => { + return y.on("change", (v) => { + if (output.length >= 10) { + } else { + output.push(formatOutput(parseTestValue(v))) + } + }) + }) + + useEffect(() => { + y.jump(createValue(100)) + + setTimeout(() => { + resolve(output) + }, 100) + }, []) + + return null + } + + const { rerender } = render() + rerender() + }) + + const resolved = await promise + + expect(resolved).toEqual([createValue(100)]) + }) + + test("unsubscribes when attached to a new value", () => { + const a = motionValue(createValue(0)) + const b = motionValue(createValue(0)) + let y: MotionValue + const Component = ({ target }: { target: MotionValue }) => { + y = useFollowValue(target) + return + } + + const { rerender } = render() + rerender() + rerender() + rerender() + rerender() + rerender() + + // Cast to any here as `.events` is private API + expect((a as any).events.change.getSize()).toBe(1) + }) + }) +} + +// Run tests for both number values and percentage values +runAnimatedValueTests() +runAnimatedValueTests("%") + +describe("useFollowValue animation events", () => { + test("triggers animationStart event when animation begins", async () => { + const promise = new Promise((resolve) => { + const Component = () => { + const x = useMotionValue(0) + const animatedX = useFollowValue(x, { + type: "spring", + stiffness: 100, + damping: 10, + }) + + useMotionValueEvent(animatedX, "animationStart", () => { + resolve(true) + }) + + useEffect(() => { + x.set(100) + }, [x]) + + return null + } + + render() + }) + + await expect(promise).resolves.toBe(true) + }) + + test("triggers animationComplete event when animation finishes", async () => { + const promise = new Promise((resolve) => { + const Component = () => { + const x = useMotionValue(0) + const animatedX = useFollowValue(x, { + type: "spring", + stiffness: 1000, + damping: 50, + }) + + useMotionValueEvent(animatedX, "animationComplete", () => { + resolve(true) + }) + + useEffect(() => { + x.set(100) + }, [x]) + + return null + } + + render() + }) + + await expect(promise).resolves.toBe(true) + }) +}) + +describe("useFollowValue with different transition types", () => { + test("accepts spring configuration", async () => { + const Component = () => { + const x = useFollowValue(0, { + type: "spring", + stiffness: 300, + damping: 20, + mass: 1, + }) + expect(x.get()).toBe(0) + return null + } + render() + }) + + test("accepts tween configuration", async () => { + const Component = () => { + const x = useFollowValue(0, { + type: "tween", + duration: 0.5, + ease: "easeInOut", + }) + expect(x.get()).toBe(0) + return null + } + render() + }) + + test("accepts delay configuration", async () => { + const Component = () => { + const x = useFollowValue(0, { + type: "spring", + delay: 0.5, + }) + expect(x.get()).toBe(0) + return null + } + render() + }) + + test("accepts repeat configuration", async () => { + const Component = () => { + const x = useFollowValue(0, { + type: "tween", + duration: 0.2, + repeat: 2, + repeatType: "reverse", + }) + expect(x.get()).toBe(0) + return null + } + render() + }) +}) diff --git a/packages/framer-motion/src/value/use-follow-value.ts b/packages/framer-motion/src/value/use-follow-value.ts new file mode 100644 index 0000000000..5357c70703 --- /dev/null +++ b/packages/framer-motion/src/value/use-follow-value.ts @@ -0,0 +1,82 @@ +"use client" + +import { + AnyResolvedKeyframe, + attachFollow, + FollowValueOptions, + isMotionValue, + MotionValue, +} from "motion-dom" +import { useContext, useInsertionEffect } from "react" +import { MotionConfigContext } from "../context/MotionConfigContext" +import { useMotionValue } from "./use-motion-value" +import { useTransform } from "./use-transform" + +/** + * Creates a `MotionValue` that, when `set`, will use the specified animation transition to animate to its new state. + * + * Unlike `useSpring` which is limited to spring animations, `useFollowValue` accepts any motion transition + * including spring, tween, inertia, and keyframes. + * + * It can either work as a stand-alone `MotionValue` by initialising it with a value, or as a subscriber + * to another `MotionValue`. + * + * @remarks + * + * ```jsx + * // Spring animation (default) + * const x = useFollowValue(0, { stiffness: 300 }) + * + * // Tween animation + * const y = useFollowValue(0, { type: "tween", duration: 0.5, ease: "easeOut" }) + * + * // Track another MotionValue with spring + * const source = useMotionValue(0) + * const z = useFollowValue(source, { type: "spring", damping: 10 }) + * + * // Inertia animation + * const w = useFollowValue(0, { type: "inertia", velocity: 100 }) + * ``` + * + * @param inputValue - `MotionValue` or number. If provided a `MotionValue`, when the input `MotionValue` changes, the created `MotionValue` will animate towards that value using the specified transition. + * @param options - Animation transition options. Supports all transition types: spring, tween, inertia, keyframes. + * @returns `MotionValue` + * + * @public + */ +export function useFollowValue( + source: MotionValue, + options?: FollowValueOptions +): MotionValue +export function useFollowValue( + source: string, + options?: FollowValueOptions +): MotionValue +export function useFollowValue( + source: MotionValue, + options?: FollowValueOptions +): MotionValue +export function useFollowValue( + source: number, + options?: FollowValueOptions +): MotionValue +export function useFollowValue( + source: MotionValue | MotionValue | AnyResolvedKeyframe, + options: FollowValueOptions = {} +) { + const { isStatic } = useContext(MotionConfigContext) + const getFromSource = () => (isMotionValue(source) ? source.get() : source) + + // isStatic will never change, allowing early hooks return + if (isStatic) { + return useTransform(getFromSource) + } + + const value = useMotionValue(getFromSource()) + + useInsertionEffect(() => { + return attachFollow(value, source, options) + }, [value, JSON.stringify(options)]) + + return value +} diff --git a/packages/framer-motion/src/value/use-spring.ts b/packages/framer-motion/src/value/use-spring.ts index acaa577df0..7cf706126a 100644 --- a/packages/framer-motion/src/value/use-spring.ts +++ b/packages/framer-motion/src/value/use-spring.ts @@ -1,16 +1,7 @@ "use client" -import { - AnyResolvedKeyframe, - attachSpring, - isMotionValue, - MotionValue, - SpringOptions, -} from "motion-dom" -import { useContext, useInsertionEffect } from "react" -import { MotionConfigContext } from "../context/MotionConfigContext" -import { useMotionValue } from "./use-motion-value" -import { useTransform } from "./use-transform" +import { MotionValue, SpringOptions } from "motion-dom" +import { useFollowValue } from "./use-follow-value" /** * Creates a `MotionValue` that, when `set`, will use a spring animation to animate to its new state. @@ -48,22 +39,8 @@ export function useSpring( options?: SpringOptions ): MotionValue export function useSpring( - source: MotionValue | MotionValue | AnyResolvedKeyframe, + source: MotionValue | MotionValue | string | number, options: SpringOptions = {} -) { - const { isStatic } = useContext(MotionConfigContext) - const getFromSource = () => (isMotionValue(source) ? source.get() : source) - - // isStatic will never change, allowing early hooks return - if (isStatic) { - return useTransform(getFromSource) - } - - const value = useMotionValue(getFromSource()) - - useInsertionEffect(() => { - return attachSpring(value, source, options) - }, [value, JSON.stringify(options)]) - - return value +): MotionValue | MotionValue { + return useFollowValue(source as any, { type: "spring", ...options }) } diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index fd528dd3eb..d60b2c5cc0 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.2", + "version": "12.28.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts index 7fa5ff426e..35b75f1268 100644 --- a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts +++ b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts @@ -211,10 +211,10 @@ describe("svgEffect", () => { await nextFrame() - // Verify initial path properties + // Verify initial path properties (uses unitless values to avoid Safari zoom bug) expect(element.getAttribute("pathLength")).toBe("1") - expect(element.getAttribute("stroke-dashoffset")).toBe("-0.5px") - expect(element.getAttribute("stroke-dasharray")).toBe("2px 1px") + expect(element.getAttribute("stroke-dashoffset")).toBe("-0.5") + expect(element.getAttribute("stroke-dasharray")).toBe("2 1") // Update values pathOffset.set("0.25") @@ -225,8 +225,8 @@ describe("svgEffect", () => { // Verify updated path properties expect(element.getAttribute("pathLength")).toBe("1") - expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25px") - expect(element.getAttribute("stroke-dasharray")).toBe("3px 2px") + expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25") + expect(element.getAttribute("stroke-dasharray")).toBe("3 2") }) it("handles path properties with cleanup", async () => { @@ -247,9 +247,9 @@ describe("svgEffect", () => { await nextFrame() - // Verify initial values - expect(element.getAttribute("stroke-dashoffset")).toBe("-0.5px") - expect(element.getAttribute("stroke-dasharray")).toBe("2px 1px") + // Verify initial values (uses unitless values to avoid Safari zoom bug) + expect(element.getAttribute("stroke-dashoffset")).toBe("-0.5") + expect(element.getAttribute("stroke-dasharray")).toBe("2 1") // Update values pathOffset.set("0.25") @@ -259,8 +259,8 @@ describe("svgEffect", () => { await nextFrame() // Verify updates - expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25px") - expect(element.getAttribute("stroke-dasharray")).toBe("3px 2px") + expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25") + expect(element.getAttribute("stroke-dasharray")).toBe("3 2") // Cleanup cleanup() @@ -273,7 +273,7 @@ describe("svgEffect", () => { await nextFrame() // Verify values didn't change after cleanup - expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25px") - expect(element.getAttribute("stroke-dasharray")).toBe("3px 2px") + expect(element.getAttribute("stroke-dashoffset")).toBe("-0.25") + expect(element.getAttribute("stroke-dasharray")).toBe("3 2") }) }) diff --git a/packages/motion-dom/src/effects/svg/index.ts b/packages/motion-dom/src/effects/svg/index.ts index aadc3b8c65..a6301e1b4f 100644 --- a/packages/motion-dom/src/effects/svg/index.ts +++ b/packages/motion-dom/src/effects/svg/index.ts @@ -1,14 +1,11 @@ import { frame } from "../../frameloop" import { MotionValue } from "../../value" -import { px } from "../../value/types/numbers/units" import { addAttrValue } from "../attr" import { MotionValueState } from "../MotionValueState" import { addStyleValue } from "../style" import { createSelectorEffect } from "../utils/create-dom-effect" import { createEffect } from "../utils/create-effect" -const toPx = px.transform! - function addSVGPathValue( element: SVGElement, state: MotionValueState, @@ -18,19 +15,20 @@ function addSVGPathValue( frame.render(() => element.setAttribute("pathLength", "1")) if (key === "pathOffset") { - return state.set(key, value, () => - element.setAttribute("stroke-dashoffset", toPx(-state.latest[key])) - ) + return state.set(key, value, () => { + // Use unitless value to avoid Safari zoom bug + const offset = state.latest[key] + element.setAttribute("stroke-dashoffset", `${-offset}`) + }) } else { if (!state.get("stroke-dasharray")) { state.set("stroke-dasharray", new MotionValue("1 1"), () => { const { pathLength = 1, pathSpacing } = state.latest + // Use unitless values to avoid Safari zoom bug element.setAttribute( "stroke-dasharray", - `${toPx(pathLength)} ${toPx( - pathSpacing ?? 1 - Number(pathLength) - )}` + `${pathLength} ${pathSpacing ?? 1 - Number(pathLength)}` ) }) } diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 102d03821f..9ca4a3159c 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -121,6 +121,7 @@ export * from "./utils/supports/scroll-timeline" export * from "./utils/transform" export * from "./value" +export * from "./value/follow-value" export * from "./value/map-value" export * from "./value/spring-value" export * from "./value/transform-value" diff --git a/packages/motion-dom/src/projection/node/DocumentProjectionNode.ts b/packages/motion-dom/src/projection/node/DocumentProjectionNode.ts index e8684532e1..d118e29787 100644 --- a/packages/motion-dom/src/projection/node/DocumentProjectionNode.ts +++ b/packages/motion-dom/src/projection/node/DocumentProjectionNode.ts @@ -7,8 +7,8 @@ export const DocumentProjectionNode = createProjectionNode({ notify: VoidFunction ): VoidFunction => addDomEvent(ref, "resize", notify), measureScroll: () => ({ - x: document.documentElement.scrollLeft || document.body.scrollLeft, - y: document.documentElement.scrollTop || document.body.scrollTop, + x: document.documentElement.scrollLeft || document.body?.scrollLeft || 0, + y: document.documentElement.scrollTop || document.body?.scrollTop || 0, }), checkIsScrollRoot: () => true, }) diff --git a/packages/motion-dom/src/projection/node/__tests__/DocumentProjectionNode.test.ts b/packages/motion-dom/src/projection/node/__tests__/DocumentProjectionNode.test.ts new file mode 100644 index 0000000000..d10e1e13ec --- /dev/null +++ b/packages/motion-dom/src/projection/node/__tests__/DocumentProjectionNode.test.ts @@ -0,0 +1,119 @@ +describe("DocumentProjectionNode", () => { + describe("measureScroll null body handling", () => { + const originalBody = document.body + + afterEach(() => { + // Restore original body after each test + Object.defineProperty(document, "body", { + value: originalBody, + writable: true, + configurable: true, + }) + }) + + test("accessing document.body?.scrollLeft does not throw when body is null", () => { + // Mock document.body as null (edge case during rapid DOM manipulation) + Object.defineProperty(document, "body", { + value: null, + writable: true, + configurable: true, + }) + + // This simulates the measureScroll logic in DocumentProjectionNode + // The fix adds optional chaining to prevent TypeError + expect(() => { + const x = + document.documentElement.scrollLeft || + document.body?.scrollLeft || + 0 + const y = + document.documentElement.scrollTop || + document.body?.scrollTop || + 0 + return { x, y } + }).not.toThrow() + }) + + test("returns 0 when both documentElement and body scroll are 0 or unavailable", () => { + Object.defineProperty(document.documentElement, "scrollLeft", { + value: 0, + configurable: true, + }) + Object.defineProperty(document.documentElement, "scrollTop", { + value: 0, + configurable: true, + }) + Object.defineProperty(document, "body", { + value: null, + writable: true, + configurable: true, + }) + + const x = + document.documentElement.scrollLeft || + document.body?.scrollLeft || + 0 + const y = + document.documentElement.scrollTop || + document.body?.scrollTop || + 0 + + expect(x).toBe(0) + expect(y).toBe(0) + }) + + test("uses documentElement scroll when available", () => { + Object.defineProperty(document.documentElement, "scrollLeft", { + value: 100, + configurable: true, + }) + Object.defineProperty(document.documentElement, "scrollTop", { + value: 200, + configurable: true, + }) + + const x = + document.documentElement.scrollLeft || + document.body?.scrollLeft || + 0 + const y = + document.documentElement.scrollTop || + document.body?.scrollTop || + 0 + + expect(x).toBe(100) + expect(y).toBe(200) + }) + + test("falls back to body scroll when documentElement scroll is 0", () => { + Object.defineProperty(document.documentElement, "scrollLeft", { + value: 0, + configurable: true, + }) + Object.defineProperty(document.documentElement, "scrollTop", { + value: 0, + configurable: true, + }) + Object.defineProperty(document.body, "scrollLeft", { + value: 50, + configurable: true, + }) + Object.defineProperty(document.body, "scrollTop", { + value: 75, + configurable: true, + }) + + const x = + document.documentElement.scrollLeft || + document.body?.scrollLeft || + 0 + const y = + document.documentElement.scrollTop || + document.body?.scrollTop || + 0 + + expect(x).toBe(50) + expect(y).toBe(75) + }) + }) +}) diff --git a/packages/motion-dom/src/render/svg/utils/path.ts b/packages/motion-dom/src/render/svg/utils/path.ts index aa76dcfcb6..1c7a23643c 100644 --- a/packages/motion-dom/src/render/svg/utils/path.ts +++ b/packages/motion-dom/src/render/svg/utils/path.ts @@ -1,4 +1,3 @@ -import { px } from "../../../value/types/numbers/units" import { ResolvedValues } from "../../types" const dashKeys = { @@ -17,6 +16,9 @@ const camelKeys = { * and stroke-dasharray attributes. * * This function is mutative to reduce per-frame GC. + * + * Note: We use unitless values for stroke-dasharray and stroke-dashoffset + * because Safari incorrectly scales px values when the page is zoomed. */ export function buildSVGPath( attrs: ResolvedValues, @@ -32,11 +34,9 @@ export function buildSVGPath( // when defining props on a React component. const keys = useDashCase ? dashKeys : camelKeys - // Build the dash offset - attrs[keys.offset] = px.transform!(-offset) + // Build the dash offset (unitless to avoid Safari zoom bug) + attrs[keys.offset] = `${-offset}` - // Build the dash array - const pathLength = px.transform!(length) - const pathSpacing = px.transform!(spacing) - attrs[keys.array] = `${pathLength} ${pathSpacing}` + // Build the dash array (unitless to avoid Safari zoom bug) + attrs[keys.array] = `${length} ${spacing}` } diff --git a/packages/motion-dom/src/value/follow-value.ts b/packages/motion-dom/src/value/follow-value.ts new file mode 100644 index 0000000000..5c4759ce87 --- /dev/null +++ b/packages/motion-dom/src/value/follow-value.ts @@ -0,0 +1,137 @@ +import { MotionValue, motionValue } from "." +import { JSAnimation } from "../animation/JSAnimation" +import { AnyResolvedKeyframe, ValueAnimationTransition } from "../animation/types" +import { frame } from "../frameloop" +import { isMotionValue } from "./utils/is-motion-value" + +/** + * Options for useFollowValue hook, extending ValueAnimationTransition + * but excluding lifecycle callbacks that don't make sense for the hook pattern. + */ +export type FollowValueOptions = Omit< + ValueAnimationTransition, + "onUpdate" | "onComplete" | "onPlay" | "onRepeat" | "onStop" +> + +/** + * Create a `MotionValue` that animates to its latest value using any transition type. + * Can either be a value or track another `MotionValue`. + * + * ```jsx + * const x = motionValue(0) + * const y = followValue(x, { type: "spring", stiffness: 300 }) + * // or with tween + * const z = followValue(x, { type: "tween", duration: 0.5, ease: "easeOut" }) + * ``` + * + * @param source - Initial value or MotionValue to track + * @param options - Animation transition options + * @returns `MotionValue` + * + * @public + */ +export function followValue( + source: T | MotionValue, + options?: FollowValueOptions +) { + const initialValue = isMotionValue(source) ? source.get() : source + const value = motionValue(initialValue) + + attachFollow(value, source, options) + + return value +} + +/** + * Attach an animation to a MotionValue that will animate whenever the value changes. + * Similar to attachSpring but supports any transition type (spring, tween, inertia, etc.) + * + * @param value - The MotionValue to animate + * @param source - Initial value or MotionValue to track + * @param options - Animation transition options + * @returns Cleanup function + * + * @public + */ +export function attachFollow( + value: MotionValue, + source: T | MotionValue, + options: FollowValueOptions = {} +): VoidFunction { + const initialValue = value.get() + + let activeAnimation: JSAnimation | null = null + let latestValue = initialValue + let latestSetter: (v: T) => void + + const unit = + typeof initialValue === "string" + ? initialValue.replace(/[\d.-]/g, "") + : undefined + + const stopAnimation = () => { + if (activeAnimation) { + activeAnimation.stop() + activeAnimation = null + } + } + + const startAnimation = () => { + stopAnimation() + + const currentValue = asNumber(value.get()) + const targetValue = asNumber(latestValue) + + // Don't animate if we're already at the target + if (currentValue === targetValue) { + return + } + + activeAnimation = new JSAnimation({ + keyframes: [currentValue, targetValue], + velocity: value.getVelocity(), + // Default to spring if no type specified (matches useSpring behavior) + type: "spring", + restDelta: 0.001, + restSpeed: 0.01, + ...options, + onUpdate: latestSetter, + }) + } + + value.attach((v, set) => { + latestValue = v + latestSetter = (latest) => set(parseValue(latest, unit) as T) + + frame.postRender(() => { + startAnimation() + value["events"].animationStart?.notify() + activeAnimation?.then(() => { + value["events"].animationComplete?.notify() + }) + }) + }, stopAnimation) + + if (isMotionValue(source)) { + const removeSourceOnChange = source.on("change", (v) => + value.set(parseValue(v, unit) as T) + ) + + const removeValueOnDestroy = value.on("destroy", removeSourceOnChange) + + return () => { + removeSourceOnChange() + removeValueOnDestroy() + } + } + + return stopAnimation +} + +function parseValue(v: AnyResolvedKeyframe, unit?: string) { + return unit ? v + unit : v +} + +function asNumber(v: AnyResolvedKeyframe) { + return typeof v === "number" ? v : parseFloat(v) +} diff --git a/packages/motion-dom/src/value/spring-value.ts b/packages/motion-dom/src/value/spring-value.ts index 3c4fdbd6bd..9242f5441c 100644 --- a/packages/motion-dom/src/value/spring-value.ts +++ b/packages/motion-dom/src/value/spring-value.ts @@ -1,8 +1,6 @@ -import { MotionValue, motionValue } from "." -import { JSAnimation } from "../animation/JSAnimation" +import { MotionValue } from "." import { AnyResolvedKeyframe, SpringOptions } from "../animation/types" -import { frame } from "../frameloop" -import { isMotionValue } from "./utils/is-motion-value" +import { attachFollow, followValue } from "./follow-value" /** * Create a `MotionValue` that animates to its latest value using a spring. @@ -10,10 +8,11 @@ import { isMotionValue } from "./utils/is-motion-value" * * ```jsx * const x = motionValue(0) - * const y = transformValue(() => x.get() * 2) // double x + * const y = springValue(x, { stiffness: 300 }) * ``` * - * @param transformer - A transform function. This function must be pure with no side-effects or conditional statements. + * @param source - Initial value or MotionValue to track + * @param options - Spring configuration options * @returns `MotionValue` * * @public @@ -22,83 +21,23 @@ export function springValue( source: T | MotionValue, options?: SpringOptions ) { - const initialValue = isMotionValue(source) ? source.get() : source - const value = motionValue(initialValue) - - attachSpring(value, source, options) - - return value + return followValue(source, { type: "spring", ...options }) } +/** + * Attach a spring animation to a MotionValue that will animate whenever the value changes. + * + * @param value - The MotionValue to animate + * @param source - Initial value or MotionValue to track + * @param options - Spring configuration options + * @returns Cleanup function + * + * @public + */ export function attachSpring( value: MotionValue, source: T | MotionValue, options?: SpringOptions ): VoidFunction { - const initialValue = value.get() - - let activeAnimation: JSAnimation | null = null - let latestValue = initialValue - let latestSetter: (v: T) => void - - const unit = - typeof initialValue === "string" - ? initialValue.replace(/[\d.-]/g, "") - : undefined - - const stopAnimation = () => { - if (activeAnimation) { - activeAnimation.stop() - activeAnimation = null - } - } - const startAnimation = () => { - stopAnimation() - - activeAnimation = new JSAnimation({ - keyframes: [asNumber(value.get()), asNumber(latestValue)], - velocity: value.getVelocity(), - type: "spring", - restDelta: 0.001, - restSpeed: 0.01, - ...options, - onUpdate: latestSetter, - }) - } - - value.attach((v, set) => { - latestValue = v - latestSetter = (latest) => set(parseValue(latest, unit) as T) - - frame.postRender(()=>{ - startAnimation() - value['events'].animationStart?.notify() - activeAnimation?.then(()=>{ - value['events'].animationComplete?.notify() - }) - }) - }, stopAnimation) - - if (isMotionValue(source)) { - const removeSourceOnChange = source.on("change", (v) => - value.set(parseValue(v, unit) as T) - ) - - const removeValueOnDestroy = value.on("destroy", removeSourceOnChange) - - return () => { - removeSourceOnChange() - removeValueOnDestroy() - } - } - - return stopAnimation -} - -function parseValue(v: AnyResolvedKeyframe, unit?: string) { - return unit ? v + unit : v -} - -function asNumber(v: AnyResolvedKeyframe) { - return typeof v === "number" ? v : parseFloat(v) + return attachFollow(value, source, { type: "spring", ...options }) } diff --git a/packages/motion/package.json b/packages/motion/package.json index f1f8de1aa8..e5e753bcf7 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.2", + "version": "12.28.0", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.27.2", + "framer-motion": "^12.28.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/tests/effects/svg.spec.ts b/tests/effects/svg.spec.ts index f67c5acc6d..1765d00fa1 100644 --- a/tests/effects/svg.spec.ts +++ b/tests/effects/svg.spec.ts @@ -1,11 +1,13 @@ import { expect, test } from "@playwright/test" test.describe("svgEffect", () => { + // Uses unitless values for stroke-dasharray and stroke-dashoffset + // to avoid Safari zoom bug where px values are incorrectly scaled test("draws pathLength as stroke-dasharray", async ({ page }) => { await page.goto("effects/path-length.html") const path = page.locator("#tick") const strokeDasharray = await path.getAttribute("stroke-dasharray") - expect(strokeDasharray).toBe("0.25px 0.75px") + expect(strokeDasharray).toBe("0.25 0.75") const pathLength = await path.getAttribute("pathLength") expect(pathLength).toBe("1") }) @@ -14,7 +16,7 @@ test.describe("svgEffect", () => { await page.goto("effects/path-offset.html") const path = page.locator("#tick") const strokeDashoffset = await path.getAttribute("stroke-dashoffset") - expect(strokeDashoffset).toBe("-0.5px") + expect(strokeDashoffset).toBe("-0.5") }) test("ensures default pathSpacing correctly creates looping effect by calculating remaining amount", async ({ @@ -23,9 +25,9 @@ test.describe("svgEffect", () => { await page.goto("effects/path-offset-loop.html") const circle = page.locator("#circle") const strokeDasharray = await circle.getAttribute("stroke-dasharray") - expect(strokeDasharray).toBe("0.5px 0.5px") + expect(strokeDasharray).toBe("0.5 0.5") const strokeDashoffset = await circle.getAttribute("stroke-dashoffset") - expect(strokeDashoffset).toBe("-0.75px") + expect(strokeDashoffset).toBe("-0.75") }) test("draws attrX as x", async ({ page }) => { diff --git a/yarn.lock b/yarn.lock index 5d03ae3257..12b92a3b74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.28.0, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.27.2 + motion-dom: ^12.28.0 motion-utils: ^12.27.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.27.2 - motion: ^12.27.2 - motion-dom: ^12.27.2 + framer-motion: ^12.28.0 + motion: ^12.28.0 + motion-dom: ^12.28.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.27.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.28.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.27.2, motion@workspace:packages/motion": +"motion@^12.28.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.27.2 + framer-motion: ^12.28.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.27.2 + motion: ^12.28.0 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.27.2 + motion: ^12.28.0 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.27.2 + framer-motion: ^12.28.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0