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 @@
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
Travel
+
Card A Title
+
+
+
+
+
+ -
+
+
+
+
How to
+
Card C Title
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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