diff --git a/CHANGELOG.md b/CHANGELOG.md index d71e45bcd4..88261d11f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.27.0] 2026-01-18 + +### Fixed + +- Adding new exports for internal use. + ## [12.26.2] 2026-01-13 ### Fixed diff --git a/dev/html/package.json b/dev/html/package.json index 424d9cfe6e..4881198014 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.26.2", + "version": "12.27.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.26.2", - "motion": "^12.26.2", - "motion-dom": "^12.26.2", + "framer-motion": "^12.27.0", + "motion": "^12.27.0", + "motion-dom": "^12.27.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/html/public/animate-layout/basic-position-change.html b/dev/html/public/animate-layout/basic-position-change.html new file mode 100644 index 0000000000..80ee30ac56 --- /dev/null +++ b/dev/html/public/animate-layout/basic-position-change.html @@ -0,0 +1,70 @@ + + + + + +
+ + + + + + diff --git a/dev/html/public/animate-layout/interrupt-animation.html b/dev/html/public/animate-layout/interrupt-animation.html new file mode 100644 index 0000000000..49aa1928c4 --- /dev/null +++ b/dev/html/public/animate-layout/interrupt-animation.html @@ -0,0 +1,115 @@ + + + + + +
+ + + + + + diff --git a/dev/html/public/animate-layout/repeat-animation.html b/dev/html/public/animate-layout/repeat-animation.html new file mode 100644 index 0000000000..70c80b6ffa --- /dev/null +++ b/dev/html/public/animate-layout/repeat-animation.html @@ -0,0 +1,77 @@ + + + + + +
+ + + + + + diff --git a/dev/html/public/animate-layout/scale-correction.html b/dev/html/public/animate-layout/scale-correction.html new file mode 100644 index 0000000000..60d7cd3896 --- /dev/null +++ b/dev/html/public/animate-layout/scale-correction.html @@ -0,0 +1,98 @@ + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/scope-with-data-layout.html b/dev/html/public/animate-layout/scope-with-data-layout.html new file mode 100644 index 0000000000..d2394b62f0 --- /dev/null +++ b/dev/html/public/animate-layout/scope-with-data-layout.html @@ -0,0 +1,71 @@ + + + + + +
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-a-ab-a.html b/dev/html/public/animate-layout/shared-element-a-ab-a.html new file mode 100644 index 0000000000..d24a87a850 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-a-ab-a.html @@ -0,0 +1,155 @@ + + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-a-b-a-replace.html b/dev/html/public/animate-layout/shared-element-a-b-a-replace.html new file mode 100644 index 0000000000..4f98a2adb9 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-a-b-a-replace.html @@ -0,0 +1,140 @@ + + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-a-b-a-reuse.html b/dev/html/public/animate-layout/shared-element-a-b-a-reuse.html new file mode 100644 index 0000000000..55692a955c --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-a-b-a-reuse.html @@ -0,0 +1,136 @@ + + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-app-store.html b/dev/html/public/animate-layout/shared-element-app-store.html new file mode 100644 index 0000000000..d27d7d1d16 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-app-store.html @@ -0,0 +1,485 @@ + + + + + + +
+ +
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-basic.html b/dev/html/public/animate-layout/shared-element-basic.html new file mode 100644 index 0000000000..2cddf73eee --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-basic.html @@ -0,0 +1,103 @@ + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-configured.html b/dev/html/public/animate-layout/shared-element-configured.html new file mode 100644 index 0000000000..540c8150e7 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-configured.html @@ -0,0 +1,89 @@ + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-crossfade.html b/dev/html/public/animate-layout/shared-element-crossfade.html new file mode 100644 index 0000000000..99cc3e7029 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-crossfade.html @@ -0,0 +1,131 @@ + + + + + +
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-nested-children-bottom.html b/dev/html/public/animate-layout/shared-element-nested-children-bottom.html new file mode 100644 index 0000000000..76c149e914 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-nested-children-bottom.html @@ -0,0 +1,416 @@ + + + + + + +
+
+
+
+
+
+
+

Card Title

+
+
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-nested-children.html b/dev/html/public/animate-layout/shared-element-nested-children.html new file mode 100644 index 0000000000..256278fb3a --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-nested-children.html @@ -0,0 +1,449 @@ + + + + + + +
+
+
+
+
+
+
+

Card Title

+
+
+
+
+ + + + + + diff --git a/dev/html/public/animate-layout/shared-element-no-crossfade.html b/dev/html/public/animate-layout/shared-element-no-crossfade.html new file mode 100644 index 0000000000..b93a27ba3a --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-no-crossfade.html @@ -0,0 +1,129 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/shared-multiple-elements.html b/dev/html/public/animate-layout/shared-multiple-elements.html new file mode 100644 index 0000000000..9a680bac37 --- /dev/null +++ b/dev/html/public/animate-layout/shared-multiple-elements.html @@ -0,0 +1,114 @@ + + + + + +
+
+ +
+ + + + + + diff --git a/dev/html/src/imports/animate-layout.js b/dev/html/src/imports/animate-layout.js new file mode 100644 index 0000000000..484c5f71f0 --- /dev/null +++ b/dev/html/src/imports/animate-layout.js @@ -0,0 +1,25 @@ +import { + LayoutAnimationBuilder, + frame, + parseAnimateLayoutArgs, +} from "framer-motion/dom" + +export function unstable_animateLayout( + scopeOrUpdateDom, + updateDomOrOptions, + options +) { + const { scope, updateDom, defaultOptions } = parseAnimateLayoutArgs( + scopeOrUpdateDom, + updateDomOrOptions, + options + ) + + return new LayoutAnimationBuilder(scope, updateDom, defaultOptions) +} + +window.AnimateLayout = { + animateLayout: unstable_animateLayout, + LayoutAnimationBuilder, + frame, +} diff --git a/dev/html/src/imports/script-assert.js b/dev/html/src/imports/script-assert.js index 5f98468dd9..9153285776 100644 --- a/dev/html/src/imports/script-assert.js +++ b/dev/html/src/imports/script-assert.js @@ -24,7 +24,13 @@ window.Assert = { Math.abs(expected.bottom - bbox.bottom) > threshold || Math.abs(expected.left - bbox.left) > threshold ) { - showError(element, "Viewport box doesn't match") + showError( + element, + "Viewport box doesn't match - " + + JSON.stringify(bbox) + + " vs " + + JSON.stringify(expected) + ) } }, matchVisibility: (element, expected) => { diff --git a/dev/inc/collect-html-tests.js b/dev/inc/collect-html-tests.js index b36541ed36..133d291dbd 100644 --- a/dev/inc/collect-html-tests.js +++ b/dev/inc/collect-html-tests.js @@ -26,5 +26,6 @@ function collect(sourceDir, outputFile) { collect("optimized-appear", "appear-tests") collect("projection", "projection-tests") +collect("animate-layout", "animate-layout-tests") console.log("HTML tests collected!") diff --git a/dev/next/package.json b/dev/next/package.json index 3d69491ffb..a378422ace 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.26.2", + "version": "12.27.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.26.2", + "motion": "^12.27.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 ae92c80cb7..0681fc7f5f 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.26.2", + "version": "12.27.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.26.2", + "motion": "^12.27.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a8d26058f6..4eef579397 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.26.2", + "version": "12.27.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.26.2", + "framer-motion": "^12.27.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index eb0aa44051..3ceb370381 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.26.2", + "version": "12.27.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json new file mode 100644 index 0000000000..ede6381074 --- /dev/null +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -0,0 +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 diff --git a/packages/framer-motion/cypress/integration-html/animate-layout.ts b/packages/framer-motion/cypress/integration-html/animate-layout.ts new file mode 100644 index 0000000000..601d46c934 --- /dev/null +++ b/packages/framer-motion/cypress/integration-html/animate-layout.ts @@ -0,0 +1,16 @@ +Cypress.config({ + baseUrl: "http://localhost:8000/animate-layout/", +}) + +describe("animateLayout API", () => { + const tests = require("../fixtures/animate-layout-tests.json") + + tests.forEach((test) => { + it(test, () => { + cy.visit(test) + cy.wait(250) + .get('[data-layout-correct="false"]') + .should("not.exist") + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4737e7f9d1..675918e9f3 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.26.2", + "version": "12.27.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.26.2", + "motion-dom": "^12.27.0", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index a48a333865..4824b33042 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.26.2", + "version": "12.27.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts index 7499925777..d7611b0e96 100644 --- a/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts +++ b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts @@ -161,15 +161,19 @@ export class AsyncMotionValueAnimation * WAAPI. Therefore, this animation must be JS to ensure it runs "under" the * optimised animation. */ - const animation = - !isHandoff && supportsBrowserAnimation(resolvedOptions) + const useWaapi = !isHandoff && supportsBrowserAnimation(resolvedOptions) + const element = resolvedOptions.motionValue?.owner?.current + + const animation = useWaapi ? new NativeAnimationExtended({ ...resolvedOptions, - element: resolvedOptions.motionValue!.owner!.current, + element, } as any) : new JSAnimation(resolvedOptions) - animation.finished.then(() => this.notifyFinished()).catch(noop) + animation.finished.then(() => { + this.notifyFinished() + }).catch(noop) if (this.pendingTimeline) { this.stopTimeline = animation.attachTimeline(this.pendingTimeline) diff --git a/packages/motion-dom/src/animation/utils/can-animate.ts b/packages/motion-dom/src/animation/utils/can-animate.ts index 4e7cee7344..2c0f5475d6 100644 --- a/packages/motion-dom/src/animation/utils/can-animate.ts +++ b/packages/motion-dom/src/animation/utils/can-animate.ts @@ -24,7 +24,9 @@ export function canAnimate( * animatable and another that isn't. */ const originKeyframe = keyframes[0] - if (originKeyframe === null) return false + if (originKeyframe === null) { + return false + } /** * These aren't traditionally animatable but we do support them. diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 3e42bdc695..102d03821f 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -7,26 +7,32 @@ export * from "./animation/NativeAnimationExtended" export * from "./animation/NativeAnimationWrapper" export * from "./animation/types" export * from "./animation/utils/active-animations" +export { calcChildStagger } from "./animation/utils/calc-child-stagger" export * from "./animation/utils/css-variables-conversion" +export { getDefaultTransition } from "./animation/utils/default-transitions" +export { getFinalKeyframe } from "./animation/utils/get-final-keyframe" export * from "./animation/utils/get-value-transition" export * from "./animation/utils/is-css-variable" -export * from "./animation/utils/make-animation-instant" -export { getDefaultTransition } from "./animation/utils/default-transitions" export { isTransitionDefined } from "./animation/utils/is-transition-defined" -export { getFinalKeyframe } from "./animation/utils/get-final-keyframe" -export { calcChildStagger } from "./animation/utils/calc-child-stagger" +export * from "./animation/utils/make-animation-instant" // Animation interfaces export { animateMotionValue } from "./animation/interfaces/motion-value" +export type { VisualElementAnimationOptions } from "./animation/interfaces/types" +export { animateVisualElement } from "./animation/interfaces/visual-element" export { animateTarget } from "./animation/interfaces/visual-element-target" export { animateVariant } from "./animation/interfaces/visual-element-variant" -export { animateVisualElement } from "./animation/interfaces/visual-element" -export type { VisualElementAnimationOptions } from "./animation/interfaces/types" // Optimized appear -export { optimizedAppearDataId, optimizedAppearDataAttribute } from "./animation/optimized-appear/data-id" +export { + optimizedAppearDataAttribute, + optimizedAppearDataId, +} from "./animation/optimized-appear/data-id" export { getOptimisedAppearId } from "./animation/optimized-appear/get-appear-id" -export type { WithAppearProps, HandoffFunction } from "./animation/optimized-appear/types" +export type { + HandoffFunction, + WithAppearProps, +} from "./animation/optimized-appear/types" export * from "./animation/generators/inertia" export * from "./animation/generators/keyframes" @@ -83,9 +89,9 @@ export * from "./render/dom/parse-transform" export * from "./render/dom/style-computed" export * from "./render/dom/style-set" export * from "./render/svg/types" +export { isKeyframesTarget } from "./render/utils/is-keyframes-target" export * from "./render/utils/keys-position" export * from "./render/utils/keys-transform" -export { isKeyframesTarget } from "./render/utils/is-keyframes-target" export * from "./resize" @@ -137,9 +143,9 @@ export * from "./value/types/utils/animatable-none" export * from "./value/types/utils/find" export * from "./value/types/utils/get-as-type" export * from "./value/utils/is-motion-value" -export type { WillChange } from "./value/will-change/types" -export { isWillChangeMotionValue } from "./value/will-change/is" export { addValueToWillChange } from "./value/will-change/add-will-change" +export { isWillChangeMotionValue } from "./value/will-change/is" +export type { WillChange } from "./value/will-change/types" export * from "./view" export * from "./view/types" @@ -147,126 +153,162 @@ export * from "./view/utils/get-layer-info" export * from "./view/utils/get-view-animations" // Visual Element -export { VisualElement, setFeatureDefinitions, getFeatureDefinitions } from "./render/VisualElement" -export type { MotionStyle } from "./render/VisualElement" -export { Feature } from "./render/Feature" export { DOMVisualElement } from "./render/dom/DOMVisualElement" +export * from "./render/dom/types" +export { Feature } from "./render/Feature" export { HTMLVisualElement } from "./render/html/HTMLVisualElement" -export { SVGVisualElement } from "./render/svg/SVGVisualElement" +export * from "./render/html/types" export { ObjectVisualElement } from "./render/object/ObjectVisualElement" export { visualElementStore } from "./render/store" +export { SVGVisualElement } from "./render/svg/SVGVisualElement" export type { - ResolvedValues, + AnimationType, + FeatureClass, + LayoutLifecycles, + MotionConfigContextProps, PresenceContextProps, ReducedMotionConfig, - MotionConfigContextProps, - VisualState, - VisualElementOptions, - VisualElementEventCallbacks, - LayoutLifecycles, + ResolvedValues, ScrapeMotionValuesFromProps, UseRenderState, - AnimationType, - FeatureClass, + VisualElementEventCallbacks, + VisualElementOptions, + VisualState, } from "./render/types" -export * from "./render/dom/types" -export * from "./render/html/types" +export { + getFeatureDefinitions, + setFeatureDefinitions, + VisualElement, +} from "./render/VisualElement" +export type { MotionStyle } from "./render/VisualElement" // Animation State -export { createAnimationState, checkVariantsDidChange } from "./render/utils/animation-state" -export type { AnimationState, AnimationTypeState, AnimationList } from "./render/utils/animation-state" +export { + checkVariantsDidChange, + createAnimationState, +} from "./render/utils/animation-state" +export type { + AnimationList, + AnimationState, + AnimationTypeState, +} from "./render/utils/animation-state" // Variant utilities -export { isVariantLabel } from "./render/utils/is-variant-label" -export { isControllingVariants, isVariantNode } from "./render/utils/is-controlling-variants" export { getVariantContext } from "./render/utils/get-variant-context" -export { resolveVariantFromProps } from "./render/utils/resolve-variants" -export { resolveVariant } from "./render/utils/resolve-dynamic-variants" -export { updateMotionValuesFromProps } from "./render/utils/motion-values" -export { variantProps, variantPriorityOrder } from "./render/utils/variant-props" export { isAnimationControls } from "./render/utils/is-animation-controls" -export { isForcedMotionValue, scaleCorrectors, addScaleCorrector } from "./render/utils/is-forced-motion-value" +export { + isControllingVariants, + isVariantNode, +} from "./render/utils/is-controlling-variants" +export { + addScaleCorrector, + isForcedMotionValue, + scaleCorrectors, +} from "./render/utils/is-forced-motion-value" +export { isVariantLabel } from "./render/utils/is-variant-label" +export { updateMotionValuesFromProps } from "./render/utils/motion-values" +export { resolveVariant } from "./render/utils/resolve-dynamic-variants" +export { resolveVariantFromProps } from "./render/utils/resolve-variants" export { setTarget } from "./render/utils/setters" +export { + variantPriorityOrder, + variantProps, +} from "./render/utils/variant-props" // Reduced motion export { - initPrefersReducedMotion, hasReducedMotionListener, + initPrefersReducedMotion, prefersReducedMotion, } from "./render/utils/reduced-motion" // Projection geometry -export * from "./projection/geometry/models" -export * from "./projection/geometry/delta-calc" +export * from "./projection/geometry/conversion" +export * from "./projection/geometry/copy" export * from "./projection/geometry/delta-apply" +export * from "./projection/geometry/delta-calc" export * from "./projection/geometry/delta-remove" -export * from "./projection/geometry/copy" -export * from "./projection/geometry/conversion" +export * from "./projection/geometry/models" export * from "./projection/geometry/utils" -export { hasTransform, hasScale, has2DTranslate } from "./projection/utils/has-transform" -export { measureViewportBox, measurePageBox } from "./projection/utils/measure" export { eachAxis } from "./projection/utils/each-axis" +export { + has2DTranslate, + hasScale, + hasTransform, +} from "./projection/utils/has-transform" +export { measurePageBox, measureViewportBox } from "./projection/utils/measure" // Projection styles -export * from "./projection/styles/types" -export { pixelsToPercent, correctBorderRadius } from "./projection/styles/scale-border-radius" +export { + correctBorderRadius, + pixelsToPercent, +} from "./projection/styles/scale-border-radius" export { correctBoxShadow } from "./projection/styles/scale-box-shadow" export { buildProjectionTransform } from "./projection/styles/transform" +export * from "./projection/styles/types" // Projection animation export { mixValues } from "./projection/animation/mix-values" // Utilities (used by projection system) -export { delay, delayInSeconds } from "./utils/delay" -export type { DelayedFunction } from "./utils/delay" -export { addDomEvent } from "./events/add-dom-event" -export { resolveMotionValue } from "./value/utils/resolve-motion-value" export { animateSingleValue } from "./animation/animate/single-value" -export { FlatTree } from "./projection/utils/flat-tree" +export { addDomEvent } from "./events/add-dom-event" export { compareByDepth } from "./projection/utils/compare-by-depth" export type { WithDepth } from "./projection/utils/compare-by-depth" +export { FlatTree } from "./projection/utils/flat-tree" +export { delay, delayInSeconds } from "./utils/delay" +export type { DelayedFunction } from "./utils/delay" +export { resolveMotionValue } from "./value/utils/resolve-motion-value" // Projection node system export { + cleanDirtyNodes, createProjectionNode, propagateDirtyNodes, - cleanDirtyNodes, } from "./projection/node/create-projection-node" +export { DocumentProjectionNode } from "./projection/node/DocumentProjectionNode" +export { nodeGroup } from "./projection/node/group" +export type { NodeGroup } from "./projection/node/group" export { HTMLProjectionNode, rootProjectionNode, } from "./projection/node/HTMLProjectionNode" -export { DocumentProjectionNode } from "./projection/node/DocumentProjectionNode" export { globalProjectionState } from "./projection/node/state" -export { nodeGroup } from "./projection/node/group" -export type { NodeGroup } from "./projection/node/group" -export { NodeStack } from "./projection/shared/stack" export type { + InitialPromotionConfig, IProjectionNode, - Measurements, - Phase, - ScrollMeasurements, LayoutEvents, LayoutUpdateData, LayoutUpdateHandler, + Measurements, + Phase, + ProjectionEventName, ProjectionNodeConfig, ProjectionNodeOptions, - ProjectionEventName, - InitialPromotionConfig, + ScrollMeasurements, } from "./projection/node/types" +export { NodeStack } from "./projection/shared/stack" // HTML/SVG utilities +export { camelToDash } from "./render/dom/utils/camel-to-dash" export { buildHTMLStyles } from "./render/html/utils/build-styles" export { buildTransform } from "./render/html/utils/build-transform" export { renderHTML } from "./render/html/utils/render" export { scrapeMotionValuesFromProps as scrapeHTMLMotionValuesFromProps } from "./render/html/utils/scrape-motion-values" export { buildSVGAttrs } from "./render/svg/utils/build-attrs" -export { renderSVG } from "./render/svg/utils/render" -export { buildSVGPath } from "./render/svg/utils/path" export { camelCaseAttributes } from "./render/svg/utils/camel-case-attrs" export { isSVGTag } from "./render/svg/utils/is-svg-tag" +export { buildSVGPath } from "./render/svg/utils/path" +export { renderSVG } from "./render/svg/utils/render" export { scrapeMotionValuesFromProps as scrapeSVGMotionValuesFromProps } from "./render/svg/utils/scrape-motion-values" -export { camelToDash } from "./render/dom/utils/camel-to-dash" + +/** + * Layout animations + */ +export { + LayoutAnimationBuilder, + parseAnimateLayoutArgs, +} from "./layout/LayoutAnimationBuilder" /** * Deprecated diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts new file mode 100644 index 0000000000..82886f9c95 --- /dev/null +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -0,0 +1,184 @@ +import { noop } from "motion-utils" +import type { AnimationOptions } from "../animation/types" +import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnimation" +import { getLayoutElements } from "./get-layout-elements" +import { + buildProjectionTree, + cleanupProjectionTree, + type ProjectionContext, + type BuildProjectionTreeOptions, +} from "./projection-tree" +import { resolveElements, type ElementOrSelector } from "../utils/resolve-elements" +import { frame } from "../frameloop" + +export class LayoutAnimationBuilder implements PromiseLike { + private scope: Element | Document + private updateDom: () => void + private defaultOptions?: AnimationOptions + + private sharedTransitions = new Map() + + private notifyReady: (value: GroupAnimation) => void = noop + private readyPromise: Promise + private executed = false + + constructor( + scope: Element | Document, + updateDom: () => void, + defaultOptions?: AnimationOptions + ) { + this.scope = scope + this.updateDom = updateDom + this.defaultOptions = defaultOptions + + this.readyPromise = new Promise((resolve) => { + this.notifyReady = resolve + }) + + // Queue execution on microtask to allow builder methods to be called + queueMicrotask(() => this.execute()) + } + + shared(id: string, options: AnimationOptions): this { + this.sharedTransitions.set(id, options) + return this + } + + then( + onfulfilled?: + | ((value: GroupAnimation) => TResult1 | PromiseLike) + | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.readyPromise.then(onfulfilled, onrejected) + } + + private async execute() { + if (this.executed) return + this.executed = true + + let context: ProjectionContext | undefined + + // Phase 1: Pre-mutation - Build projection tree and take snapshots + const beforeElements = getLayoutElements(this.scope) + + if (beforeElements.length > 0) { + context = buildProjectionTree( + beforeElements, + undefined, + this.getBuildOptions() + ) + + context.root.startUpdate() + + for (const node of context.nodes.values()) { + node.isLayoutDirty = false + node.willUpdate() + } + } + + // Phase 2: Execute DOM update + this.updateDom() + + // Phase 3: Post-mutation - Compare before/after elements + const afterElements = getLayoutElements(this.scope) + const beforeSet = new Set(beforeElements) + const afterSet = new Set(afterElements) + + const entering = afterElements.filter((el) => !beforeSet.has(el)) + const exiting = beforeElements.filter((el) => !afterSet.has(el)) + + // Build projection nodes for entering elements + if (entering.length > 0) { + context = buildProjectionTree( + entering, + context, + this.getBuildOptions() + ) + } + + // No layout elements - return empty animation + if (!context) { + this.notifyReady(new GroupAnimation([])) + return + } + + // Handle shared elements + for (const element of exiting) { + const node = context.nodes.get(element) + node?.getStack()?.remove(node) + } + + for (const element of entering) { + context.nodes.get(element)?.promote() + } + + // Phase 4: Animate + context.root.didUpdate() + + await new Promise((resolve) => + frame.postRender(() => resolve()) + ) + + const animations: AcceptedAnimations[] = [] + for (const node of context.nodes.values()) { + if (node.currentAnimation) { + animations.push(node.currentAnimation) + } + } + + const groupAnimation = new GroupAnimation(animations) + + groupAnimation.finished.then(() => { + cleanupProjectionTree(context!) + }) + + this.notifyReady(groupAnimation) + } + + private getBuildOptions(): BuildProjectionTreeOptions { + return { + defaultTransition: this.defaultOptions || { + duration: 0.3, + ease: "easeOut", + }, + sharedTransitions: + this.sharedTransitions.size > 0 + ? this.sharedTransitions + : undefined, + } + } + +} + +/** + * Parse arguments for animateLayout overloads + */ +export function parseAnimateLayoutArgs( + scopeOrUpdateDom: ElementOrSelector | (() => void), + updateDomOrOptions?: (() => void) | AnimationOptions, + options?: AnimationOptions +): { + scope: Element | Document + updateDom: () => void + defaultOptions?: AnimationOptions +} { + // animateLayout(updateDom) + if (typeof scopeOrUpdateDom === "function") { + return { + scope: document, + updateDom: scopeOrUpdateDom, + defaultOptions: updateDomOrOptions as AnimationOptions | undefined, + } + } + + // animateLayout(scope, updateDom, options?) + const elements = resolveElements(scopeOrUpdateDom) + const scope = elements[0] || document + + return { + scope: scope instanceof Document ? scope : scope, + updateDom: updateDomOrOptions as () => void, + defaultOptions: options, + } +} diff --git a/packages/motion-dom/src/layout/get-layout-elements.ts b/packages/motion-dom/src/layout/get-layout-elements.ts new file mode 100644 index 0000000000..b640256fb8 --- /dev/null +++ b/packages/motion-dom/src/layout/get-layout-elements.ts @@ -0,0 +1,23 @@ +const LAYOUT_SELECTOR = "[data-layout], [data-layout-id]" + +export function getLayoutElements(scope: Element | Document): HTMLElement[] { + const elements = Array.from(scope.querySelectorAll(LAYOUT_SELECTOR)) as HTMLElement[] + + // Include scope itself if it's an Element (not Document) and has layout attributes + if (scope instanceof Element && hasLayout(scope)) { + elements.unshift(scope as HTMLElement) + } + + return elements +} + +export function getLayoutId(element: Element): string | null { + return element.getAttribute("data-layout-id") +} + +function hasLayout(element: Element): boolean { + return ( + element.hasAttribute("data-layout") || + element.hasAttribute("data-layout-id") + ) +} diff --git a/packages/motion-dom/src/layout/projection-tree.ts b/packages/motion-dom/src/layout/projection-tree.ts new file mode 100644 index 0000000000..2b9705cb88 --- /dev/null +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -0,0 +1,285 @@ +import type { AnimationOptions } from "../animation/types" +import type { + IProjectionNode, + ProjectionNodeOptions, +} from "../projection/node/types" +import { HTMLProjectionNode } from "../projection/node/HTMLProjectionNode" +import { HTMLVisualElement } from "../render/html/HTMLVisualElement" +import { nodeGroup, type NodeGroup } from "../projection/node/group" +import { getLayoutId } from "./get-layout-elements" +import { addScaleCorrector } from "../render/utils/is-forced-motion-value" +import { correctBorderRadius } from "../projection/styles/scale-border-radius" +import { correctBoxShadow } from "../projection/styles/scale-box-shadow" + +let scaleCorrectorAdded = false + +/** + * Track active projection nodes per element to handle animation interruption. + * When a new animation starts on an element that already has an active animation, + * we need to stop the old animation so the new one can start from the current + * visual position. + */ +const activeProjectionNodes = new WeakMap() + +function ensureScaleCorrectors() { + if (scaleCorrectorAdded) return + scaleCorrectorAdded = true + + addScaleCorrector({ + borderRadius: { + ...correctBorderRadius, + applyTo: [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomLeftRadius", + "borderBottomRightRadius", + ], + }, + borderTopLeftRadius: correctBorderRadius, + borderTopRightRadius: correctBorderRadius, + borderBottomLeftRadius: correctBorderRadius, + borderBottomRightRadius: correctBorderRadius, + boxShadow: correctBoxShadow, + }) +} + +export interface ProjectionContext { + nodes: Map + visualElements: Map + group: NodeGroup + root: IProjectionNode +} + +/** + * Get DOM depth of an element + */ +function getDepth(element: Element): number { + let depth = 0 + let current = element.parentElement + while (current) { + depth++ + current = current.parentElement + } + return depth +} + +/** + * Find the closest projection parent for an element + */ +function findProjectionParent( + element: HTMLElement, + nodeCache: Map +): IProjectionNode | undefined { + let parent = element.parentElement as HTMLElement | null + while (parent) { + const node = nodeCache.get(parent) + if (node) return node + parent = parent.parentElement as HTMLElement | null + } + return undefined +} + +/** + * Create or reuse a projection node for an element + */ +function createProjectionNode( + element: HTMLElement, + parent: IProjectionNode | undefined, + options: ProjectionNodeOptions, + transition?: AnimationOptions +): { node: IProjectionNode; visualElement: HTMLVisualElement } { + // Check for existing active node - reuse it to preserve animation state + const existingNode = activeProjectionNodes.get(element) + if (existingNode) { + const visualElement = existingNode.options.visualElement as HTMLVisualElement + + // Update transition options for the new animation + const nodeTransition = transition + ? { duration: transition.duration, ease: transition.ease as any } + : { duration: 0.3, ease: "easeOut" } + + existingNode.setOptions({ + ...existingNode.options, + animate: true, + transition: nodeTransition, + ...options, + }) + + // Re-mount the node if it was previously unmounted + // This re-adds it to root.nodes so didUpdate() will process it + if (!existingNode.instance) { + existingNode.mount(element) + } + + return { node: existingNode, visualElement } + } + + // No existing node - create a new one + const latestValues: Record = {} + + const visualElement = new HTMLVisualElement({ + visualState: { + latestValues, + renderState: { + transformOrigin: {}, + transform: {}, + style: {}, + vars: {}, + }, + }, + presenceContext: null, + props: {}, + }) + + const node = new HTMLProjectionNode(latestValues, parent) + + // Convert AnimationOptions to transition format for the projection system + const nodeTransition = transition + ? { duration: transition.duration, ease: transition.ease as any } + : { duration: 0.3, ease: "easeOut" } + + node.setOptions({ + visualElement, + layout: true, + animate: true, + transition: nodeTransition, + ...options, + }) + + node.mount(element) + visualElement.projection = node + + // Track this node as the active one for this element + activeProjectionNodes.set(element, node) + + return { node, visualElement } +} + +export interface BuildProjectionTreeOptions { + defaultTransition?: AnimationOptions + sharedTransitions?: Map +} + +/** + * Build a projection tree from a list of elements + */ +export function buildProjectionTree( + elements: HTMLElement[], + existingContext?: ProjectionContext, + options?: BuildProjectionTreeOptions +): ProjectionContext { + ensureScaleCorrectors() + + const nodes = existingContext?.nodes ?? new Map() + const visualElements = + existingContext?.visualElements ?? new Map() + const group = existingContext?.group ?? nodeGroup() + + const defaultTransition = options?.defaultTransition + const sharedTransitions = options?.sharedTransitions + + // Sort elements by DOM depth (parents before children) + const sorted = [...elements].sort((a, b) => getDepth(a) - getDepth(b)) + + let root: IProjectionNode | undefined = existingContext?.root + + for (const element of sorted) { + // Skip if already has a node + if (nodes.has(element)) continue + + const parent = findProjectionParent(element, nodes) + const layoutId = getLayoutId(element) + const layoutMode = element.getAttribute("data-layout") + + const nodeOptions: ProjectionNodeOptions = { + layoutId: layoutId ?? undefined, + animationType: parseLayoutMode(layoutMode), + } + + // Use layoutId-specific transition if available, otherwise use default + const transition = layoutId && sharedTransitions?.get(layoutId) + ? sharedTransitions.get(layoutId) + : defaultTransition + + const { node, visualElement } = createProjectionNode( + element, + parent, + nodeOptions, + transition + ) + + nodes.set(element, node) + visualElements.set(element, visualElement) + group.add(node) + + if (!root) { + root = node.root + } + } + + return { + nodes, + visualElements, + group, + root: root!, + } +} + +/** + * Parse the data-layout attribute value + */ +function parseLayoutMode( + value: string | null +): "size" | "position" | "both" | "preserve-aspect" { + if (value === "position") return "position" + if (value === "size") return "size" + if (value === "preserve-aspect") return "preserve-aspect" + return "both" +} + +/** + * Clean up projection nodes for specific elements. + * If elementsToCleanup is provided, only those elements are cleaned up. + * If not provided, all nodes are cleaned up. + * + * This allows persisting elements to keep their nodes between animations, + * matching React's behavior where nodes persist for elements that remain in the DOM. + */ +export function cleanupProjectionTree( + context: ProjectionContext, + elementsToCleanup?: Set +) { + const elementsToProcess = elementsToCleanup + ? [...context.nodes.entries()].filter(([el]) => elementsToCleanup.has(el)) + : [...context.nodes.entries()] + + for (const [element, node] of elementsToProcess) { + context.group.remove(node) + node.unmount() + + // Only clear from activeProjectionNodes if this is still the active node. + // A newer animation might have already taken over. + if (activeProjectionNodes.get(element) === node) { + activeProjectionNodes.delete(element) + } + + context.nodes.delete(element) + context.visualElements.delete(element) + } +} + +/** + * Set a value on a projection node's visual element + */ +export function setNodeValue( + context: ProjectionContext, + element: HTMLElement, + key: string, + value: any +) { + const visualElement = context.visualElements.get(element) + if (visualElement) { + visualElement.latestValues[key] = value + visualElement.scheduleRender() + } +} diff --git a/packages/motion-dom/src/layout/types.ts b/packages/motion-dom/src/layout/types.ts new file mode 100644 index 0000000000..8d4c09bebc --- /dev/null +++ b/packages/motion-dom/src/layout/types.ts @@ -0,0 +1,4 @@ +import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/types" +import type { ElementOrSelector } from "../utils/resolve-elements" + +export type { AnimationOptions, DOMKeyframesDefinition, ElementOrSelector } diff --git a/packages/motion/package.json b/packages/motion/package.json index 90e529a5dc..de10e7e543 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.26.2", + "version": "12.27.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.26.2", + "framer-motion": "^12.27.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 090daab0cf..fc24c247e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.26.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.27.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.26.2 + motion-dom: ^12.27.0 motion-utils: ^12.24.10 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.26.2 - motion: ^12.26.2 - motion-dom: ^12.26.2 + framer-motion: ^12.27.0 + motion: ^12.27.0 + motion-dom: ^12.27.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.26.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.27.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.26.2, motion@workspace:packages/motion": +"motion@^12.27.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.26.2 + framer-motion: ^12.27.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.26.2 + motion: ^12.27.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.26.2 + motion: ^12.27.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.26.2 + framer-motion: ^12.27.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0