diff --git a/CHANGELOG.md b/CHANGELOG.md index 228f1fcd2c..4b9df6a74c 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.29.1] 2026-01-22 + +### Fixed + +- `useAnimate`: Now respects reduced motion settings set via `MotionConfig`. + ## [12.29.0] 2026-01-22 ### Added diff --git a/dev/html/package.json b/dev/html/package.json index 6c1ddd7363..9baf76bfdf 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.29.0", + "version": "12.28.1-alpha.1", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.29.0", - "motion": "^12.29.0", - "motion-dom": "^12.29.0", + "framer-motion": "^12.28.1-alpha.1", + "motion": "^12.28.1-alpha.1", + "motion-dom": "^12.28.1-alpha.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/html/public/animate-layout/modal-open-close-interrupt.html b/dev/html/public/animate-layout/modal-open-close-interrupt.html new file mode 100644 index 0000000000..3132fdf81c --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-close-interrupt.html @@ -0,0 +1,248 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/modal-open-close-open-interrupt.html b/dev/html/public/animate-layout/modal-open-close-open-interrupt.html new file mode 100644 index 0000000000..f1b622e7ec --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-close-open-interrupt.html @@ -0,0 +1,248 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/modal-open-close-open.html b/dev/html/public/animate-layout/modal-open-close-open.html new file mode 100644 index 0000000000..046d4df26a --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-close-open.html @@ -0,0 +1,247 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/modal-open-close.html b/dev/html/public/animate-layout/modal-open-close.html new file mode 100644 index 0000000000..b9b334b45c --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-close.html @@ -0,0 +1,245 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/modal-open-opacity.html b/dev/html/public/animate-layout/modal-open-opacity.html new file mode 100644 index 0000000000..97d4beb3be --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-opacity.html @@ -0,0 +1,242 @@ + + + + + + + + + + + + diff --git a/dev/html/public/animate-layout/modal-open.html b/dev/html/public/animate-layout/modal-open.html new file mode 100644 index 0000000000..4e20fbdb30 --- /dev/null +++ b/dev/html/public/animate-layout/modal-open.html @@ -0,0 +1,233 @@ + + + + + + + + + + + + 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 index d24a87a850..ff049b5075 100644 --- a/dev/html/public/animate-layout/shared-element-a-ab-a.html +++ b/dev/html/public/animate-layout/shared-element-a-ab-a.html @@ -82,7 +82,6 @@ cardBig.id = "card-big" cardBig.className = "card big" cardBig.setAttribute("data-layout-id", "a") - cardBig.setAttribute("data-layout-exit", "true") container.appendChild(cardBig) }, { duration: 0.05, ease: "linear" } diff --git a/dev/next/package.json b/dev/next/package.json index f7515def9d..b7909b8a12 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.29.0", + "version": "12.28.1-alpha.1", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.29.0", + "motion": "^12.28.1-alpha.1", "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 c8fe0dcf1c..3376dfc03e 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.29.0", + "version": "12.28.1-alpha.1", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.29.0", + "motion": "^12.28.1-alpha.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 0e778a543c..5240e36ade 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.29.0", + "version": "12.28.1-alpha.1", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.29.0", + "framer-motion": "^12.28.1-alpha.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index c9fff6f1ba..d50dd42e49 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.29.0", + "version": "12.28.1-alpha.1", "packages": [ "packages/*", "dev/*" diff --git a/packages/config/package.json b/packages/config/package.json index 9e924a77bc..136e3ddb4c 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "config", - "version": "12.24.10", + "version": "12.28.1-alpha.1", "main": "index.js", "private": true, "license": "MIT", diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 5597a3e3a6..b7090502f4 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["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 +["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","modal-open-close-interrupt.html","modal-open-close-open-interrupt.html","modal-open-close-open.html","modal-open-close.html","modal-open-opacity.html","modal-open.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-html/animate-layout.ts b/packages/framer-motion/cypress/integration-html/animate-layout.ts index 601d46c934..70a0fe6a53 100644 --- a/packages/framer-motion/cypress/integration-html/animate-layout.ts +++ b/packages/framer-motion/cypress/integration-html/animate-layout.ts @@ -8,7 +8,7 @@ describe("animateLayout API", () => { tests.forEach((test) => { it(test, () => { cy.visit(test) - cy.wait(250) + cy.wait(500) .get('[data-layout-correct="false"]') .should("not.exist") }) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index d3b73ee501..31a23aefdb 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.29.0", + "version": "12.28.1-alpha.1", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,8 +88,8 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.29.0", - "motion-utils": "^12.27.2", + "motion-dom": "^12.28.1-alpha.1", + "motion-utils": "^12.28.1-alpha.1", "tslib": "^2.4.0" }, "devDependencies": { diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 40eb04d216..e180acb39a 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.29.0", + "version": "12.28.1-alpha.1", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", @@ -17,7 +17,7 @@ } }, "dependencies": { - "motion-utils": "^12.27.2" + "motion-utils": "^12.28.1-alpha.1" }, "scripts": { "clean": "rm -rf types dist lib", diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 0ed9097e79..3a802fd021 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -1,168 +1,234 @@ -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 type { Box } from "motion-utils" +import { GroupAnimation } from "../animation/GroupAnimation" +import type { + AnimationOptions, + AnimationPlaybackControls, + Transition, +} from "../animation/types" import { frame } from "../frameloop" +import { copyBoxInto } from "../projection/geometry/copy" +import { createBox } from "../projection/geometry/models" +import { HTMLProjectionNode } from "../projection/node/HTMLProjectionNode" +import type { IProjectionNode } from "../projection/node/types" +import { HTMLVisualElement } from "../render/html/HTMLVisualElement" +import { visualElementStore } from "../render/store" +import type { VisualElement } from "../render/VisualElement" +import { resolveElements, type ElementOrSelector } from "../utils/resolve-elements" -export class LayoutAnimationBuilder implements PromiseLike { - private scope: Element | Document - private updateDom: () => void - private defaultOptions?: AnimationOptions +type LayoutAnimationScope = Element | Document - private sharedTransitions = new Map() +interface LayoutElementRecord { + element: Element + visualElement: VisualElement + projection: IProjectionNode +} + +interface LayoutAttributes { + layout?: boolean | "position" | "size" | "preserve-aspect" + layoutId?: string +} + +type LayoutBuilderResolve = (animation: GroupAnimation) => void +type LayoutBuilderReject = (error: unknown) => void + +interface ProjectionOptions { + layout?: boolean | "position" | "size" | "preserve-aspect" + layoutId?: string + animationType?: "size" | "position" | "both" | "preserve-aspect" + transition?: Transition + crossfade?: boolean +} + +const layoutSelector = "[data-layout], [data-layout-id]" +const noop = () => {} +function snapshotFromTarget(projection: IProjectionNode): LayoutElementRecord["projection"]["snapshot"] { + const target = projection.targetWithTransforms || projection.target + if (!target) return undefined + + const measuredBox = createBox() + const layoutBox = createBox() + copyBoxInto(measuredBox, target as Box) + copyBoxInto(layoutBox, target as Box) - private notifyReady: (value: GroupAnimation) => void = noop + return { + animationId: projection.root?.animationId ?? 0, + measuredBox, + layoutBox, + latestValues: projection.animationValues || projection.latestValues || {}, + source: projection.id, + } +} + +export class LayoutAnimationBuilder { + private scope: LayoutAnimationScope + private updateDom: () => void | Promise + private defaultOptions?: AnimationOptions + private sharedTransitions = new Map() + private notifyReady: LayoutBuilderResolve = noop + private rejectReady: LayoutBuilderReject = noop private readyPromise: Promise - private executed = false constructor( - scope: Element | Document, - updateDom: () => void, + scope: LayoutAnimationScope, + updateDom: () => void | Promise, defaultOptions?: AnimationOptions ) { this.scope = scope this.updateDom = updateDom this.defaultOptions = defaultOptions - this.readyPromise = new Promise((resolve) => { + this.readyPromise = new Promise((resolve, reject) => { this.notifyReady = resolve + this.rejectReady = reject }) - // Queue execution on microtask to allow builder methods to be called - queueMicrotask(() => this.execute()) + frame.postRender(() => { + this.start().then(this.notifyReady).catch(this.rejectReady) + }) } - shared(id: string, options: AnimationOptions): this { - this.sharedTransitions.set(id, options) + shared(id: string, transition: AnimationOptions): this { + this.sharedTransitions.set(id, transition) return this } - then( - onfulfilled?: - | ((value: GroupAnimation) => TResult1 | PromiseLike) - | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null - ): Promise { - return this.readyPromise.then(onfulfilled, onrejected) + then( + resolve: LayoutBuilderResolve, + reject?: LayoutBuilderReject + ): Promise { + return this.readyPromise.then(resolve, reject) } - 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() + private async start(): Promise { + const beforeElements = collectLayoutElements(this.scope) + const beforeRecords = this.buildRecords(beforeElements) + + beforeRecords.forEach(({ projection }) => { + const hasCurrentAnimation = Boolean(projection.currentAnimation) + const isSharedLayout = Boolean(projection.options.layoutId) + if (hasCurrentAnimation && isSharedLayout) { + const snapshot = snapshotFromTarget(projection) + if (snapshot) { + projection.snapshot = snapshot + } else if (projection.snapshot) { + projection.snapshot = undefined + } + } else if ( + projection.snapshot && + (projection.currentAnimation || projection.isProjecting()) + ) { + projection.snapshot = undefined + } + projection.isPresent = true + projection.willUpdate() + }) - for (const node of context.nodes.values()) { - node.isLayoutDirty = false - node.willUpdate() + await this.updateDom() + + const afterElements = collectLayoutElements(this.scope) + const afterRecords = this.buildRecords(afterElements) + this.handleExitingElements(beforeRecords, afterRecords) + + afterRecords.forEach(({ projection }) => { + const instance = projection.instance as HTMLElement | undefined + const resumeFromInstance = projection.resumeFrom + ?.instance as HTMLElement | undefined + if (!instance || !resumeFromInstance) return + if (!("style" in instance)) return + + const currentTransform = instance.style.transform + const resumeFromTransform = resumeFromInstance.style.transform + + if ( + currentTransform && + resumeFromTransform && + currentTransform === resumeFromTransform + ) { + instance.style.transform = "" + instance.style.transformOrigin = "" } - } + }) - // Phase 2: Execute DOM update - this.updateDom() + afterRecords.forEach(({ projection }) => { + projection.isPresent = true + }) - // Phase 3: Post-mutation - Compare before/after elements - const afterElements = getLayoutElements(this.scope) - const beforeSet = new Set(beforeElements) - const afterSet = new Set(afterElements) + const root = getProjectionRoot(afterRecords, beforeRecords) + root?.didUpdate() - const entering = afterElements.filter((el) => !beforeSet.has(el)) - const exiting = beforeElements.filter((el) => !afterSet.has(el)) + await new Promise((resolve) => { + frame.postRender(() => resolve()) + }) - // Build projection nodes for entering elements - if (entering.length > 0) { - context = buildProjectionTree( - entering, - context, - this.getBuildOptions() - ) - } + const animations = collectAnimations(afterRecords) + const animation = new GroupAnimation(animations) - // No layout elements - return empty animation - if (!context) { - this.notifyReady(new GroupAnimation([])) - return - } + return animation + } - // Handle shared elements - for (const element of exiting) { - const node = context.nodes.get(element) - node?.getStack()?.remove(node) + private buildRecords(elements: Element[]): LayoutElementRecord[] { + const records: LayoutElementRecord[] = [] + const recordMap = new Map() + + for (const element of elements) { + const parentRecord = findParentRecord(element, recordMap, this.scope) + const { layout, layoutId } = readLayoutAttributes(element) + const override = layoutId + ? this.sharedTransitions.get(layoutId) + : undefined + const transition = override || this.defaultOptions + const record = getOrCreateRecord(element, parentRecord?.projection, { + layout, + layoutId, + animationType: typeof layout === "string" ? layout : "both", + transition: transition as Transition, + }) + recordMap.set(element, record) + records.push(record) } - for (const element of entering) { - context.nodes.get(element)?.promote() - } + return records + } - // Phase 4: Animate - context.root.didUpdate() + private handleExitingElements( + beforeRecords: LayoutElementRecord[], + afterRecords: LayoutElementRecord[] + ): void { + const afterElementsSet = new Set(afterRecords.map((record) => record.element)) - await new Promise((resolve) => - frame.postRender(() => resolve()) - ) + beforeRecords.forEach((record) => { + if (afterElementsSet.has(record.element)) return - const animations: AcceptedAnimations[] = [] - for (const node of context.nodes.values()) { - if (node.currentAnimation) { - animations.push(node.currentAnimation) + // For shared layout elements, relegate to set up resumeFrom + // so the remaining element animates from this position + if (record.projection.options.layoutId) { + record.projection.isPresent = false + record.projection.relegate() } - } - const groupAnimation = new GroupAnimation(animations) + record.visualElement.unmount() + visualElementStore.delete(record.element) + }) - groupAnimation.finished.then(() => { - // Only clean up nodes for elements no longer in the document. - // Elements still in DOM keep their nodes so subsequent animations - // can use the stored position snapshots (A→B→A pattern). - const elementsToCleanup = new Set() - for (const element of context!.nodes.keys()) { - if (!document.contains(element)) { - elementsToCleanup.add(element) - } + // Clear resumeFrom on EXISTING nodes that point to unmounted projections + // This prevents crossfade animation when the source element was removed entirely + // But preserve resumeFrom for NEW nodes so they can animate from the old position + // Also preserve resumeFrom for lead nodes that were just promoted via relegate + const beforeElementsSet = new Set(beforeRecords.map((record) => record.element)) + afterRecords.forEach(({ element, projection }) => { + if ( + beforeElementsSet.has(element) && + projection.resumeFrom && + !projection.resumeFrom.instance && + !projection.isLead() + ) { + projection.resumeFrom = undefined + projection.snapshot = undefined } - cleanupProjectionTree(context!, elementsToCleanup) }) - - 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, @@ -186,8 +252,130 @@ export function parseAnimateLayoutArgs( const scope = elements[0] || document return { - scope: scope instanceof Document ? scope : scope, + scope, updateDom: updateDomOrOptions as () => void, defaultOptions: options, } } + +function collectLayoutElements(scope: LayoutAnimationScope): Element[] { + const elements = Array.from(scope.querySelectorAll(layoutSelector)) + + if (scope instanceof Element && scope.matches(layoutSelector)) { + if (!elements.includes(scope)) { + elements.unshift(scope) + } + } + + return elements +} + +function readLayoutAttributes(element: Element): LayoutAttributes { + const layoutId = element.getAttribute("data-layout-id") || undefined + const rawLayout = element.getAttribute("data-layout") + let layout: LayoutAttributes["layout"] + + if (rawLayout === "" || rawLayout === "true") { + layout = true + } else if (rawLayout) { + layout = rawLayout as LayoutAttributes["layout"] + } + + return { + layout, + layoutId, + } +} + +function createVisualState() { + return { + latestValues: {}, + renderState: { + transform: {}, + transformOrigin: {}, + style: {}, + vars: {}, + }, + } +} + +function getOrCreateRecord( + element: Element, + parentProjection?: IProjectionNode, + projectionOptions?: ProjectionOptions +): LayoutElementRecord { + const existing = visualElementStore.get(element) as VisualElement | undefined + const visualElement = + existing ?? + new HTMLVisualElement( + { + props: {}, + presenceContext: null, + visualState: createVisualState(), + }, + { allowProjection: true } + ) + + if (!existing || !visualElement.projection) { + visualElement.projection = new HTMLProjectionNode( + visualElement.latestValues, + parentProjection + ) + } + + visualElement.projection.setOptions({ + ...projectionOptions, + visualElement, + }) + + if (!visualElement.current) { + visualElement.mount(element as HTMLElement) + } + + if (!existing) { + visualElementStore.set(element, visualElement) + } + + return { + element, + visualElement, + projection: visualElement.projection as IProjectionNode, + } +} + +function findParentRecord( + element: Element, + recordMap: Map, + scope: LayoutAnimationScope +) { + let parent = element.parentElement + + while (parent) { + const record = recordMap.get(parent) + if (record) return record + + if (parent === scope) break + parent = parent.parentElement + } + + return undefined +} + +function getProjectionRoot( + afterRecords: LayoutElementRecord[], + beforeRecords: LayoutElementRecord[] +) { + const record = afterRecords[0] || beforeRecords[0] + return record?.projection.root +} + +function collectAnimations(afterRecords: LayoutElementRecord[]) { + const animations = new Set() + + afterRecords.forEach((record) => { + const animation = record.projection.currentAnimation + if (animation) animations.add(animation) + }) + + return Array.from(animations) +} diff --git a/packages/motion-dom/src/layout/get-layout-elements.ts b/packages/motion-dom/src/layout/get-layout-elements.ts deleted file mode 100644 index b640256fb8..0000000000 --- a/packages/motion-dom/src/layout/get-layout-elements.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 2b9705cb88..0000000000 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index 8d4c09bebc..0000000000 --- a/packages/motion-dom/src/layout/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/types" -import type { ElementOrSelector } from "../utils/resolve-elements" - -export type { AnimationOptions, DOMKeyframesDefinition, ElementOrSelector } diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 63feb136ea..75220f16e4 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1,11 +1,36 @@ -import { activeAnimations } from "../../stats/animation-count" +import { + Axis, + AxisDelta, + Box, + clamp, + Delta, + noop, + Point, + SubscriptionManager, +} from "motion-utils" +import { animateSingleValue } from "../../animation/animate/single-value" import { JSAnimation } from "../../animation/JSAnimation" +import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appear-id" import { Transition, ValueAnimationOptions } from "../../animation/types" import { getValueTransition } from "../../animation/utils/get-value-transition" import { cancelFrame, frame, frameData, frameSteps } from "../../frameloop" import { microtask } from "../../frameloop/microtask" import { time } from "../../frameloop/sync-time" import type { Process } from "../../frameloop/types" +import { HTMLVisualElement } from "../../render/html/HTMLVisualElement" +import type { ResolvedValues } from "../../render/types" +import { scaleCorrectors } from "../../render/utils/is-forced-motion-value" +import type { MotionStyle, VisualElement } from "../../render/VisualElement" +import { activeAnimations } from "../../stats/animation-count" +import { statsBuffer } from "../../stats/buffer" +import { delay } from "../../utils/delay" +import { isSVGElement } from "../../utils/is-svg-element" +import { isSVGSVGElement } from "../../utils/is-svg-svg-element" +import { mixNumber } from "../../utils/mix/number" +import { MotionValue, motionValue } from "../../value" +import { resolveMotionValue } from "../../value/utils/resolve-motion-value" +import { mixValues } from "../animation/mix-values" +import { copyAxisDeltaInto, copyBoxInto } from "../geometry/copy" import { applyBoxDelta, applyTreeDeltas, @@ -20,7 +45,6 @@ import { isNear, } from "../geometry/delta-calc" import { removeBoxTransforms } from "../geometry/delta-remove" -import { copyAxisDeltaInto, copyBoxInto } from "../geometry/copy" import { createBox, createDelta } from "../geometry/models" import { aspectRatio, @@ -29,35 +53,11 @@ import { boxEqualsRounded, isDeltaZero, } from "../geometry/utils" +import { NodeStack } from "../shared/stack" import { buildProjectionTransform } from "../styles/transform" import { eachAxis } from "../utils/each-axis" -import { has2DTranslate, hasScale, hasTransform } from "../utils/has-transform" -import { mixValues } from "../animation/mix-values" -import { isSVGElement } from "../../utils/is-svg-element" -import { isSVGSVGElement } from "../../utils/is-svg-svg-element" -import { mixNumber } from "../../utils/mix/number" -import { MotionValue, motionValue } from "../../value" -import { scaleCorrectors } from "../../render/utils/is-forced-motion-value" -import { statsBuffer } from "../../stats/buffer" -import { - Axis, - AxisDelta, - Box, - clamp, - Delta, - noop, - Point, - SubscriptionManager, -} from "motion-utils" -import { animateSingleValue } from "../../animation/animate/single-value" -import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appear-id" -import type { ResolvedValues } from "../../render/types" -import type { MotionStyle, VisualElement } from "../../render/VisualElement" -import { HTMLVisualElement } from "../../render/html/HTMLVisualElement" import { FlatTree } from "../utils/flat-tree" -import { delay } from "../../utils/delay" -import { resolveMotionValue } from "../../value/utils/resolve-motion-value" -import { NodeStack } from "../shared/stack" +import { has2DTranslate, hasScale, hasTransform } from "../utils/has-transform" import { globalProjectionState } from "./state" import { IProjectionNode, diff --git a/packages/motion-utils/package.json b/packages/motion-utils/package.json index e58196a0e9..48ee38f221 100644 --- a/packages/motion-utils/package.json +++ b/packages/motion-utils/package.json @@ -1,6 +1,6 @@ { "name": "motion-utils", - "version": "12.27.2", + "version": "12.28.1-alpha.1", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 891d169c4a..6df3e9495b 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.29.0", + "version": "12.28.1-alpha.1", "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.29.0", + "framer-motion": "^12.28.1-alpha.1", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index a31578b94d..a552412f1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,15 +7420,15 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.29.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.28.1-alpha.1, 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.29.0 - motion-utils: ^12.27.2 + motion-dom: ^12.28.1-alpha.1 + motion-utils: ^12.28.1-alpha.1 three: 0.137.0 tslib: ^2.4.0 peerDependencies: @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.29.0 - motion: ^12.29.0 - motion-dom: ^12.29.0 + framer-motion: ^12.28.1-alpha.1 + motion: ^12.28.1-alpha.1 + motion-dom: ^12.28.1-alpha.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,11 +10936,11 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.29.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.28.1-alpha.1, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: - motion-utils: ^12.27.2 + motion-utils: ^12.28.1-alpha.1 languageName: unknown linkType: soft @@ -11007,17 +11007,17 @@ __metadata: languageName: unknown linkType: soft -"motion-utils@^12.27.2, motion-utils@workspace:packages/motion-utils": +"motion-utils@^12.28.1-alpha.1, motion-utils@workspace:packages/motion-utils": version: 0.0.0-use.local resolution: "motion-utils@workspace:packages/motion-utils" languageName: unknown linkType: soft -"motion@^12.29.0, motion@workspace:packages/motion": +"motion@^12.28.1-alpha.1, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.29.0 + framer-motion: ^12.28.1-alpha.1 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.29.0 + motion: ^12.28.1-alpha.1 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.29.0 + motion: ^12.28.1-alpha.1 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.29.0 + framer-motion: ^12.28.1-alpha.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0