From aa6e76536b108a8a79f0f540db14a08df529a5c3 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 13 Jan 2026 15:32:26 +0100 Subject: [PATCH 01/25] Add animateLayout API for vanilla JS layout animations Introduces a new `animateLayout` function that enables FLIP-style layout animations without React. Key features: - Automatic before/after snapshot detection - Enter/exit animations with `.enter()` and `.exit()` builder methods - Shared element transitions via `data-layout-id` attribute - Crossfade support for elements with matching layout IDs - Scale correction for border-radius and box-shadow Co-Authored-By: Claude Opus 4.5 --- .../animate-layout/basic-position-change.html | 70 ++++ .../enter-animation-scoped.html | 94 +++++ .../animate-layout/enter-animation.html | 88 +++++ .../public/animate-layout/exit-animation.html | 117 ++++++ .../animate-layout/scale-correction.html | 98 +++++ .../animate-layout/shared-element-basic.html | 103 ++++++ .../shared-element-configured.html | 89 +++++ .../shared-element-crossfade.html | 131 +++++++ .../shared-multiple-elements.html | 114 ++++++ dev/html/src/imports/animate-layout.js | 11 + dev/inc/collect-html-tests.js | 1 + .../fixtures/animate-layout-tests.json | 1 + .../integration-html/animate-layout.ts | 16 + .../animation/AsyncMotionValueAnimation.ts | 16 +- .../src/animation/utils/can-animate.ts | 14 +- .../animation/waapi/start-waapi-animation.ts | 15 + packages/motion-dom/src/index.ts | 6 + .../src/layout/LayoutAnimationBuilder.ts | 346 ++++++++++++++++++ .../motion-dom/src/layout/animate-layout.ts | 56 +++ .../motion-dom/src/layout/detect-mutations.ts | 133 +++++++ .../motion-dom/src/layout/projection-tree.ts | 228 ++++++++++++ packages/motion-dom/src/layout/types.ts | 19 + 22 files changed, 1757 insertions(+), 9 deletions(-) create mode 100644 dev/html/public/animate-layout/basic-position-change.html create mode 100644 dev/html/public/animate-layout/enter-animation-scoped.html create mode 100644 dev/html/public/animate-layout/enter-animation.html create mode 100644 dev/html/public/animate-layout/exit-animation.html create mode 100644 dev/html/public/animate-layout/scale-correction.html create mode 100644 dev/html/public/animate-layout/shared-element-basic.html create mode 100644 dev/html/public/animate-layout/shared-element-configured.html create mode 100644 dev/html/public/animate-layout/shared-element-crossfade.html create mode 100644 dev/html/public/animate-layout/shared-multiple-elements.html create mode 100644 dev/html/src/imports/animate-layout.js create mode 100644 packages/framer-motion/cypress/fixtures/animate-layout-tests.json create mode 100644 packages/framer-motion/cypress/integration-html/animate-layout.ts create mode 100644 packages/motion-dom/src/layout/LayoutAnimationBuilder.ts create mode 100644 packages/motion-dom/src/layout/animate-layout.ts create mode 100644 packages/motion-dom/src/layout/detect-mutations.ts create mode 100644 packages/motion-dom/src/layout/projection-tree.ts create mode 100644 packages/motion-dom/src/layout/types.ts 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/enter-animation-scoped.html b/dev/html/public/animate-layout/enter-animation-scoped.html new file mode 100644 index 0000000000..88957c77a9 --- /dev/null +++ b/dev/html/public/animate-layout/enter-animation-scoped.html @@ -0,0 +1,94 @@ + + + + + + +
+
+ + + + + + diff --git a/dev/html/public/animate-layout/enter-animation.html b/dev/html/public/animate-layout/enter-animation.html new file mode 100644 index 0000000000..299e26b6d9 --- /dev/null +++ b/dev/html/public/animate-layout/enter-animation.html @@ -0,0 +1,88 @@ + + + + + +
+ + + + + + diff --git a/dev/html/public/animate-layout/exit-animation.html b/dev/html/public/animate-layout/exit-animation.html new file mode 100644 index 0000000000..0845f7dbef --- /dev/null +++ b/dev/html/public/animate-layout/exit-animation.html @@ -0,0 +1,117 @@ + + + + + +
+
+
+ + + + + + 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/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-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..58b2ec1ced --- /dev/null +++ b/dev/html/src/imports/animate-layout.js @@ -0,0 +1,11 @@ +import { + unstable_animateLayout, + LayoutAnimationBuilder, + frame, +} from "framer-motion/dom" + +window.AnimateLayout = { + animateLayout: unstable_animateLayout, + LayoutAnimationBuilder, + frame, +} 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/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..5919adef9c --- /dev/null +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -0,0 +1 @@ +["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","scale-correction.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-multiple-elements.html"] 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/motion-dom/src/animation/AsyncMotionValueAnimation.ts b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts index 7499925777..f832a2e6b7 100644 --- a/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts +++ b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts @@ -161,15 +161,23 @@ 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 el = element as HTMLElement | undefined + console.log("[AsyncMotionValueAnimation] useWaapi:", useWaapi, "element:", el?.id || el?.tagName, "duration:", resolvedOptions.duration) + + const animation = useWaapi ? new NativeAnimationExtended({ ...resolvedOptions, - element: resolvedOptions.motionValue!.owner!.current, + element, } as any) : new JSAnimation(resolvedOptions) - animation.finished.then(() => this.notifyFinished()).catch(noop) + console.log("[AsyncMotionValueAnimation] animation created, type:", animation.constructor.name, "duration:", animation.duration) + animation.finished.then(() => { + console.log("[AsyncMotionValueAnimation] underlying animation finished") + 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..cd63b3efd1 100644 --- a/packages/motion-dom/src/animation/utils/can-animate.ts +++ b/packages/motion-dom/src/animation/utils/can-animate.ts @@ -18,13 +18,17 @@ export function canAnimate( type?: AnimationGeneratorType, velocity?: number ) { + console.log("[canAnimate] name:", name, "keyframes:", keyframes, "type:", type, "velocity:", velocity) /** * Check if we're able to animate between the start and end keyframes, * and throw a warning if we're attempting to animate between one that's * animatable and another that isn't. */ const originKeyframe = keyframes[0] - if (originKeyframe === null) return false + if (originKeyframe === null) { + console.log("[canAnimate] returning false - originKeyframe is null") + return false + } /** * These aren't traditionally animatable but we do support them. @@ -50,8 +54,8 @@ export function canAnimate( return false } - return ( - hasKeyframesChanged(keyframes) || - ((type === "spring" || isGenerator(type)) && velocity) - ) + const changed = hasKeyframesChanged(keyframes) + const result = changed || ((type === "spring" || isGenerator(type)) && velocity) + console.log("[canAnimate] hasKeyframesChanged:", changed, "result:", result) + return result } diff --git a/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts b/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts index da25356192..74a9695703 100644 --- a/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts +++ b/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts @@ -46,6 +46,21 @@ export function startWaapiAnimation( const animation = element.animate(keyframeOptions, options) + console.log("[startWaapiAnimation] created animation:", { + duration: options.duration, + easing: options.easing, + keyframes: keyframeOptions, + playState: animation.playState, + currentTime: animation.currentTime, + }) + + // Debug: Check when WAAPI animation finishes + animation.finished.then(() => { + console.log("[startWaapiAnimation] WAAPI finished, currentTime:", animation.currentTime, "playState:", animation.playState) + }).catch(e => { + console.log("[startWaapiAnimation] WAAPI finished with error:", e) + }) + if (statsBuffer.value) { animation.finished.finally(() => { activeAnimations.waapi-- diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 3e42bdc695..d599d652bd 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -268,6 +268,12 @@ export { isSVGTag } from "./render/svg/utils/is-svg-tag" export { scrapeMotionValuesFromProps as scrapeSVGMotionValuesFromProps } from "./render/svg/utils/scrape-motion-values" export { camelToDash } from "./render/dom/utils/camel-to-dash" +/** + * Layout animations + */ +export { unstable_animateLayout } from "./layout/animate-layout" +export { LayoutAnimationBuilder } 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..1ca9b4876c --- /dev/null +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -0,0 +1,346 @@ +import { noop } from "motion-utils" +import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/types" +import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnimation" +import { animateTarget } from "../animation/interfaces/visual-element-target" +import type { MutationResult, RemovedElement } from "./types" +import { + snapshotElements, + detectMutations, + isRootEnteringElement, + isRootExitingElement, +} from "./detect-mutations" +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 mutation: () => void + private defaultOptions?: AnimationOptions + + private enterKeyframes?: DOMKeyframesDefinition + private enterOptions?: AnimationOptions + private exitKeyframes?: DOMKeyframesDefinition + private exitOptions?: AnimationOptions + private sharedTransitions = new Map() + + private notifyReady: (value: GroupAnimation) => void = noop + private readyPromise: Promise + private executed = false + + constructor( + scope: Element | Document, + mutation: () => void, + defaultOptions?: AnimationOptions + ) { + this.scope = scope + this.mutation = mutation + 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()) + } + + enter(keyframes: DOMKeyframesDefinition, options?: AnimationOptions): this { + this.enterKeyframes = keyframes + this.enterOptions = options + return this + } + + exit(keyframes: DOMKeyframesDefinition, options?: AnimationOptions): this { + this.exitKeyframes = keyframes + this.exitOptions = options + return this + } + + shared( + layoutIdOrOptions: string | AnimationOptions, + options?: AnimationOptions + ): this { + if (typeof layoutIdOrOptions === "string") { + this.sharedTransitions.set(layoutIdOrOptions, options!) + } + // For now, we ignore default shared options as the projection system + // handles shared transitions automatically + 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 + + const animations: AcceptedAnimations[] = [] + let context: ProjectionContext | undefined + + try { + // Phase 1: Pre-mutation (Snapshot) + const beforeSnapshots = snapshotElements(this.scope) + const existingElements = Array.from(beforeSnapshots.keys()) + + // Build projection tree for existing elements + if (existingElements.length > 0) { + context = buildProjectionTree( + existingElements, + undefined, + this.getBuildOptions() + ) + + // Start update cycle + context.root.startUpdate() + + // Call willUpdate on all nodes to capture snapshots + for (const node of context.nodes.values()) { + node.willUpdate() + } + } + + // Phase 2: Execute mutation + this.mutation() + + // Phase 3: Post-mutation (Detect & Prepare) + const mutationResult = detectMutations(beforeSnapshots, this.scope) + + // Reattach exiting elements that are NOT part of shared transitions + // Shared elements are handled by the projection system via resumeFrom + const nonSharedExiting = mutationResult.exiting.filter( + ({ element }) => { + const layoutId = element.getAttribute("data-layout-id") + return !layoutId || !mutationResult.sharedEntering.has(layoutId) + } + ) + this.reattachExitingElements(nonSharedExiting) + + // Build projection nodes for entering elements + if (mutationResult.entering.length > 0) { + context = buildProjectionTree( + mutationResult.entering, + context, + this.getBuildOptions() + ) + } + + // Also ensure persisting elements have nodes if context didn't exist + if (!context && mutationResult.persisting.length > 0) { + context = buildProjectionTree( + mutationResult.persisting, + undefined, + this.getBuildOptions() + ) + } + + // Build set of shared exiting elements to exclude from animation collection + // Their nodes are still in the tree for resumeFrom relationship, but we don't animate them + const sharedExitingElements = new Set() + for (const [layoutId] of mutationResult.sharedEntering) { + const exitingElement = mutationResult.sharedExiting.get(layoutId) + if (exitingElement) { + sharedExitingElements.add(exitingElement) + } + } + + // Phase 4: Animate + if (context) { + // Trigger layout animations via didUpdate + context.root.didUpdate() + + // Wait for animations to be created (they're scheduled via frame.update) + await new Promise((resolve) => frame.postRender(() => resolve())) + + // Collect layout animations from projection nodes (excluding shared exiting elements) + for (const [element, node] of context.nodes.entries()) { + if (sharedExitingElements.has(element)) continue + if (node.currentAnimation) { + animations.push(node.currentAnimation) + } + } + + // Apply enter keyframes to root entering elements + if (this.enterKeyframes) { + const enterAnimations = this.animateEntering(mutationResult, context) + animations.push(...enterAnimations) + } + + // Apply exit keyframes to root exiting elements + if (this.exitKeyframes) { + const exitAnimations = this.animateExiting(mutationResult, context) + animations.push(...exitAnimations) + } + } + + // Create and return group animation + const groupAnimation = new GroupAnimation(animations) + + // Phase 5: Setup cleanup on complete + groupAnimation.finished.then(() => { + // Only clean up non-shared exiting elements (those we reattached) + this.cleanupExitingElements(nonSharedExiting) + if (context) { + cleanupProjectionTree(context) + } + }) + + this.notifyReady(groupAnimation) + } catch (error) { + // Cleanup on error + if (context) { + cleanupProjectionTree(context) + } + throw error + } + } + + private getBuildOptions(): BuildProjectionTreeOptions { + return { + defaultTransition: this.defaultOptions || { duration: 0.3, ease: "easeOut" }, + sharedTransitions: this.sharedTransitions.size > 0 ? this.sharedTransitions : undefined, + } + } + + private reattachExitingElements(exiting: RemovedElement[]) { + for (const { element, parentElement, nextSibling, bounds } of exiting) { + // Check if parent still exists in DOM + if (!parentElement.isConnected) continue + + // Reattach element + if (nextSibling && nextSibling.parentNode === parentElement) { + parentElement.insertBefore(element, nextSibling) + } else { + parentElement.appendChild(element) + } + + // Apply absolute positioning to prevent layout shift + const htmlElement = element as HTMLElement + htmlElement.style.position = "absolute" + htmlElement.style.top = `${bounds.top}px` + htmlElement.style.left = `${bounds.left}px` + htmlElement.style.width = `${bounds.width}px` + htmlElement.style.height = `${bounds.height}px` + htmlElement.style.margin = "0" + htmlElement.style.pointerEvents = "none" + } + } + + private cleanupExitingElements(exiting: RemovedElement[]) { + for (const { element } of exiting) { + if (element.parentElement) { + element.parentElement.removeChild(element) + } + } + } + + private animateEntering( + mutationResult: MutationResult, + context: ProjectionContext + ): AcceptedAnimations[] { + const enteringSet = new Set(mutationResult.entering) + + // Find root entering elements + const rootEntering = mutationResult.entering.filter((el) => + isRootEnteringElement(el, enteringSet) + ) + + const animations: AcceptedAnimations[] = [] + + for (const element of rootEntering) { + const visualElement = context.visualElements.get(element) + if (!visualElement) continue + + // If entering with opacity: 1, start from opacity: 0 + const keyframes = { ...this.enterKeyframes } + if (keyframes.opacity !== undefined) { + const targetOpacity = Array.isArray(keyframes.opacity) + ? keyframes.opacity[keyframes.opacity.length - 1] + : keyframes.opacity + + if (targetOpacity === 1) { + ;(element as HTMLElement).style.opacity = "0" + } + } + + const options = this.enterOptions || this.defaultOptions || {} + const enterAnims = animateTarget(visualElement, keyframes as any, { + transitionOverride: options as any, + }) + animations.push(...enterAnims) + } + + return animations + } + + private animateExiting( + mutationResult: MutationResult, + context: ProjectionContext + ): AcceptedAnimations[] { + const exitingSet = new Set(mutationResult.exiting.map((r) => r.element)) + + // Find root exiting elements + const rootExiting = mutationResult.exiting.filter((r) => + isRootExitingElement(r.element, exitingSet) + ) + + const animations: AcceptedAnimations[] = [] + + for (const { element } of rootExiting) { + const visualElement = context.visualElements.get(element) + if (!visualElement) continue + + const options = this.exitOptions || this.defaultOptions || {} + const exitAnims = animateTarget(visualElement, this.exitKeyframes as any, { + transitionOverride: options as any, + }) + animations.push(...exitAnims) + } + + return animations + } +} + +/** + * Parse arguments for animateLayout overloads + */ +export function parseAnimateLayoutArgs( + scopeOrMutation: ElementOrSelector | (() => void), + mutationOrOptions?: (() => void) | AnimationOptions, + options?: AnimationOptions +): { + scope: Element | Document + mutation: () => void + defaultOptions?: AnimationOptions +} { + // animateLayout(mutation) + if (typeof scopeOrMutation === "function") { + return { + scope: document, + mutation: scopeOrMutation, + defaultOptions: mutationOrOptions as AnimationOptions | undefined, + } + } + + // animateLayout(scope, mutation, options?) + const elements = resolveElements(scopeOrMutation) + const scope = elements[0] || document + + return { + scope: scope instanceof Document ? scope : scope, + mutation: mutationOrOptions as () => void, + defaultOptions: options, + } +} diff --git a/packages/motion-dom/src/layout/animate-layout.ts b/packages/motion-dom/src/layout/animate-layout.ts new file mode 100644 index 0000000000..7528d13e16 --- /dev/null +++ b/packages/motion-dom/src/layout/animate-layout.ts @@ -0,0 +1,56 @@ +import type { AnimationOptions } from "../animation/types" +import type { ElementOrSelector } from "../utils/resolve-elements" +import { + LayoutAnimationBuilder, + parseAnimateLayoutArgs, +} from "./LayoutAnimationBuilder" + +/** + * Animate layout changes within a DOM tree. + * + * @example + * ```typescript + * // Basic usage - animates all elements with data-layout or data-layout-id + * await animateLayout(() => { + * container.innerHTML = newContent + * }) + * + * // With scope - only animates within the container + * await animateLayout(".container", () => { + * updateContent() + * }) + * + * // With options + * await animateLayout(() => update(), { duration: 0.5 }) + * + * // Builder pattern for enter/exit animations + * animateLayout(".cards", () => { + * container.innerHTML = newCards + * }, { duration: 0.3 }) + * .enter({ opacity: 1, scale: 1 }, { duration: 0.2 }) + * .exit({ opacity: 0, scale: 0.8 }) + * .shared("hero", { type: "spring" }) + * ``` + * + * Elements are animated if they have: + * - `data-layout` attribute (layout animation only) + * - `data-layout-id` attribute (shared element transitions) + * + * @param scopeOrMutation - Either a scope selector/element, or the mutation function + * @param mutationOrOptions - Either the mutation function or animation options + * @param options - Animation options (when scope is provided) + * @returns A builder that resolves to animation controls + */ +export function unstable_animateLayout( + scopeOrMutation: ElementOrSelector | (() => void), + mutationOrOptions?: (() => void) | AnimationOptions, + options?: AnimationOptions +): LayoutAnimationBuilder { + const { scope, mutation, defaultOptions } = parseAnimateLayoutArgs( + scopeOrMutation, + mutationOrOptions, + options + ) + + return new LayoutAnimationBuilder(scope, mutation, defaultOptions) +} diff --git a/packages/motion-dom/src/layout/detect-mutations.ts b/packages/motion-dom/src/layout/detect-mutations.ts new file mode 100644 index 0000000000..ab4b4cb4c7 --- /dev/null +++ b/packages/motion-dom/src/layout/detect-mutations.ts @@ -0,0 +1,133 @@ +import type { MutationResult, RemovedElement } from "./types" + +const LAYOUT_SELECTOR = "[data-layout], [data-layout-id]" + +export function getLayoutElements(scope: Element | Document): HTMLElement[] { + return Array.from(scope.querySelectorAll(LAYOUT_SELECTOR)) as HTMLElement[] +} + +export function getLayoutId(element: Element): string | null { + return element.getAttribute("data-layout-id") +} + +export function hasLayout(element: Element): boolean { + return ( + element.hasAttribute("data-layout") || + element.hasAttribute("data-layout-id") + ) +} + +interface ElementSnapshot { + element: HTMLElement + parentElement: HTMLElement + nextSibling: Node | null + bounds: DOMRect + layoutId: string | null +} + +/** + * Snapshot elements before mutation to track removals + */ +export function snapshotElements( + scope: Element | Document +): Map { + const elements = getLayoutElements(scope) + const snapshots = new Map() + + for (const element of elements) { + snapshots.set(element, { + element, + parentElement: element.parentElement as HTMLElement, + nextSibling: element.nextSibling, + bounds: element.getBoundingClientRect(), + layoutId: getLayoutId(element), + }) + } + + return snapshots +} + +/** + * Compare before/after snapshots to detect entering/exiting/persisting elements + */ +export function detectMutations( + beforeSnapshots: Map, + scope: Element | Document +): MutationResult { + const afterElements = new Set(getLayoutElements(scope)) + const beforeElements = new Set(beforeSnapshots.keys()) + + const entering: HTMLElement[] = [] + const exiting: RemovedElement[] = [] + const persisting: HTMLElement[] = [] + const sharedEntering = new Map() + const sharedExiting = new Map() + + // Find exiting elements (were in before, not in after) + for (const element of beforeElements) { + if (!afterElements.has(element)) { + const snapshot = beforeSnapshots.get(element)! + exiting.push({ + element, + parentElement: snapshot.parentElement, + nextSibling: snapshot.nextSibling, + bounds: snapshot.bounds, + }) + + if (snapshot.layoutId) { + sharedExiting.set(snapshot.layoutId, element) + } + } + } + + // Find entering and persisting elements + for (const element of afterElements) { + if (!beforeElements.has(element)) { + entering.push(element) + const layoutId = getLayoutId(element) + if (layoutId) { + sharedEntering.set(layoutId, element) + } + } else { + persisting.push(element) + } + } + + return { + entering, + exiting, + persisting, + sharedEntering, + sharedExiting, + } +} + +/** + * Check if an element is a "root" entering element (no entering ancestors) + */ +export function isRootEnteringElement( + element: Element, + allEntering: Set +): boolean { + let parent = element.parentElement + while (parent) { + if (allEntering.has(parent)) return false + parent = parent.parentElement + } + return true +} + +/** + * Check if an element is a "root" exiting element (no exiting ancestors) + */ +export function isRootExitingElement( + element: Element, + allExiting: Set +): boolean { + let parent = element.parentElement + while (parent) { + if (allExiting.has(parent)) return false + parent = parent.parentElement + } + return true +} 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..604a6ec347 --- /dev/null +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -0,0 +1,228 @@ +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 "./detect-mutations" +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 + +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 a single projection node for an element + */ +function createProjectionNode( + element: HTMLElement, + parent: IProjectionNode | undefined, + options: ProjectionNodeOptions, + transition?: AnimationOptions +): { node: IProjectionNode; visualElement: HTMLVisualElement } { + 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 + + 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 + */ +export function cleanupProjectionTree(context: ProjectionContext) { + for (const node of context.nodes.values()) { + context.group.remove(node) + node.unmount() + } + context.nodes.clear() + context.visualElements.clear() +} + +/** + * 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..5b97da6442 --- /dev/null +++ b/packages/motion-dom/src/layout/types.ts @@ -0,0 +1,19 @@ +import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/types" +import type { ElementOrSelector } from "../utils/resolve-elements" + +export type { AnimationOptions, DOMKeyframesDefinition, ElementOrSelector } + +export interface RemovedElement { + element: HTMLElement + parentElement: HTMLElement + nextSibling: Node | null + bounds: DOMRect +} + +export interface MutationResult { + entering: HTMLElement[] + exiting: RemovedElement[] + persisting: HTMLElement[] + sharedEntering: Map + sharedExiting: Map +} From b2fb0cb7696a6777ce7040ff5d771395c8d59c54 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 13 Jan 2026 17:11:45 +0100 Subject: [PATCH 02/25] Remove debug console.log statements from animation files Co-Authored-By: Claude Opus 4.5 --- .../src/animation/AsyncMotionValueAnimation.ts | 4 ---- .../motion-dom/src/animation/utils/can-animate.ts | 10 ++++------ .../src/animation/waapi/start-waapi-animation.ts | 15 --------------- 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts index f832a2e6b7..d7611b0e96 100644 --- a/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts +++ b/packages/motion-dom/src/animation/AsyncMotionValueAnimation.ts @@ -163,8 +163,6 @@ export class AsyncMotionValueAnimation */ const useWaapi = !isHandoff && supportsBrowserAnimation(resolvedOptions) const element = resolvedOptions.motionValue?.owner?.current - const el = element as HTMLElement | undefined - console.log("[AsyncMotionValueAnimation] useWaapi:", useWaapi, "element:", el?.id || el?.tagName, "duration:", resolvedOptions.duration) const animation = useWaapi ? new NativeAnimationExtended({ @@ -173,9 +171,7 @@ export class AsyncMotionValueAnimation } as any) : new JSAnimation(resolvedOptions) - console.log("[AsyncMotionValueAnimation] animation created, type:", animation.constructor.name, "duration:", animation.duration) animation.finished.then(() => { - console.log("[AsyncMotionValueAnimation] underlying animation finished") this.notifyFinished() }).catch(noop) diff --git a/packages/motion-dom/src/animation/utils/can-animate.ts b/packages/motion-dom/src/animation/utils/can-animate.ts index cd63b3efd1..2c0f5475d6 100644 --- a/packages/motion-dom/src/animation/utils/can-animate.ts +++ b/packages/motion-dom/src/animation/utils/can-animate.ts @@ -18,7 +18,6 @@ export function canAnimate( type?: AnimationGeneratorType, velocity?: number ) { - console.log("[canAnimate] name:", name, "keyframes:", keyframes, "type:", type, "velocity:", velocity) /** * Check if we're able to animate between the start and end keyframes, * and throw a warning if we're attempting to animate between one that's @@ -26,7 +25,6 @@ export function canAnimate( */ const originKeyframe = keyframes[0] if (originKeyframe === null) { - console.log("[canAnimate] returning false - originKeyframe is null") return false } @@ -54,8 +52,8 @@ export function canAnimate( return false } - const changed = hasKeyframesChanged(keyframes) - const result = changed || ((type === "spring" || isGenerator(type)) && velocity) - console.log("[canAnimate] hasKeyframesChanged:", changed, "result:", result) - return result + return ( + hasKeyframesChanged(keyframes) || + ((type === "spring" || isGenerator(type)) && velocity) + ) } diff --git a/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts b/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts index 74a9695703..da25356192 100644 --- a/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts +++ b/packages/motion-dom/src/animation/waapi/start-waapi-animation.ts @@ -46,21 +46,6 @@ export function startWaapiAnimation( const animation = element.animate(keyframeOptions, options) - console.log("[startWaapiAnimation] created animation:", { - duration: options.duration, - easing: options.easing, - keyframes: keyframeOptions, - playState: animation.playState, - currentTime: animation.currentTime, - }) - - // Debug: Check when WAAPI animation finishes - animation.finished.then(() => { - console.log("[startWaapiAnimation] WAAPI finished, currentTime:", animation.currentTime, "playState:", animation.playState) - }).catch(e => { - console.log("[startWaapiAnimation] WAAPI finished with error:", e) - }) - if (statsBuffer.value) { animation.finished.finally(() => { activeAnimations.waapi-- From bf12beee159e89b6048081e73200a2c8e95b3846 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 13 Jan 2026 17:15:09 +0100 Subject: [PATCH 03/25] Rename mutation prop to updateDom in LayoutAnimationBuilder Co-Authored-By: Claude Opus 4.5 --- .../src/layout/LayoutAnimationBuilder.ts | 30 +++++++++---------- .../motion-dom/src/layout/animate-layout.ts | 16 +++++----- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 1ca9b4876c..9c6cbf97c0 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -20,7 +20,7 @@ import { frame } from "../frameloop" export class LayoutAnimationBuilder implements PromiseLike { private scope: Element | Document - private mutation: () => void + private updateDom: () => void private defaultOptions?: AnimationOptions private enterKeyframes?: DOMKeyframesDefinition @@ -35,11 +35,11 @@ export class LayoutAnimationBuilder implements PromiseLike { constructor( scope: Element | Document, - mutation: () => void, + updateDom: () => void, defaultOptions?: AnimationOptions ) { this.scope = scope - this.mutation = mutation + this.updateDom = updateDom this.defaultOptions = defaultOptions this.readyPromise = new Promise((resolve) => { @@ -112,8 +112,8 @@ export class LayoutAnimationBuilder implements PromiseLike { } } - // Phase 2: Execute mutation - this.mutation() + // Phase 2: Execute DOM update + this.updateDom() // Phase 3: Post-mutation (Detect & Prepare) const mutationResult = detectMutations(beforeSnapshots, this.scope) @@ -317,30 +317,30 @@ export class LayoutAnimationBuilder implements PromiseLike { * Parse arguments for animateLayout overloads */ export function parseAnimateLayoutArgs( - scopeOrMutation: ElementOrSelector | (() => void), - mutationOrOptions?: (() => void) | AnimationOptions, + scopeOrUpdateDom: ElementOrSelector | (() => void), + updateDomOrOptions?: (() => void) | AnimationOptions, options?: AnimationOptions ): { scope: Element | Document - mutation: () => void + updateDom: () => void defaultOptions?: AnimationOptions } { - // animateLayout(mutation) - if (typeof scopeOrMutation === "function") { + // animateLayout(updateDom) + if (typeof scopeOrUpdateDom === "function") { return { scope: document, - mutation: scopeOrMutation, - defaultOptions: mutationOrOptions as AnimationOptions | undefined, + updateDom: scopeOrUpdateDom, + defaultOptions: updateDomOrOptions as AnimationOptions | undefined, } } - // animateLayout(scope, mutation, options?) - const elements = resolveElements(scopeOrMutation) + // animateLayout(scope, updateDom, options?) + const elements = resolveElements(scopeOrUpdateDom) const scope = elements[0] || document return { scope: scope instanceof Document ? scope : scope, - mutation: mutationOrOptions as () => void, + updateDom: updateDomOrOptions as () => void, defaultOptions: options, } } diff --git a/packages/motion-dom/src/layout/animate-layout.ts b/packages/motion-dom/src/layout/animate-layout.ts index 7528d13e16..314280718e 100644 --- a/packages/motion-dom/src/layout/animate-layout.ts +++ b/packages/motion-dom/src/layout/animate-layout.ts @@ -36,21 +36,21 @@ import { * - `data-layout` attribute (layout animation only) * - `data-layout-id` attribute (shared element transitions) * - * @param scopeOrMutation - Either a scope selector/element, or the mutation function - * @param mutationOrOptions - Either the mutation function or animation options + * @param scopeOrUpdateDom - Either a scope selector/element, or the DOM update function + * @param updateDomOrOptions - Either the DOM update function or animation options * @param options - Animation options (when scope is provided) * @returns A builder that resolves to animation controls */ export function unstable_animateLayout( - scopeOrMutation: ElementOrSelector | (() => void), - mutationOrOptions?: (() => void) | AnimationOptions, + scopeOrUpdateDom: ElementOrSelector | (() => void), + updateDomOrOptions?: (() => void) | AnimationOptions, options?: AnimationOptions ): LayoutAnimationBuilder { - const { scope, mutation, defaultOptions } = parseAnimateLayoutArgs( - scopeOrMutation, - mutationOrOptions, + const { scope, updateDom, defaultOptions } = parseAnimateLayoutArgs( + scopeOrUpdateDom, + updateDomOrOptions, options ) - return new LayoutAnimationBuilder(scope, mutation, defaultOptions) + return new LayoutAnimationBuilder(scope, updateDom, defaultOptions) } From 74c1d6f7c522c04c2130bfdca3e497af6b281acb Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 13 Jan 2026 17:31:11 +0100 Subject: [PATCH 04/25] Use projection system snapshots instead of getBoundingClientRect Refactored animateLayout to leverage the projection system's existing snapshot mechanism rather than using raw getBoundingClientRect. This eliminates duplicate measurements and ensures proper transform-aware measurements via willUpdate(). Co-Authored-By: Claude Opus 4.5 --- .../src/layout/LayoutAnimationBuilder.ts | 42 ++++++++++++------- .../motion-dom/src/layout/detect-mutations.ts | 34 +++++++-------- packages/motion-dom/src/layout/types.ts | 1 - 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 9c6cbf97c0..05982d63b4 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -4,7 +4,8 @@ import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnima import { animateTarget } from "../animation/interfaces/visual-element-target" import type { MutationResult, RemovedElement } from "./types" import { - snapshotElements, + trackLayoutElements, + getLayoutElements, detectMutations, isRootEnteringElement, isRootExitingElement, @@ -91,11 +92,11 @@ export class LayoutAnimationBuilder implements PromiseLike { let context: ProjectionContext | undefined try { - // Phase 1: Pre-mutation (Snapshot) - const beforeSnapshots = snapshotElements(this.scope) - const existingElements = Array.from(beforeSnapshots.keys()) + // Phase 1: Pre-mutation - Build projection tree and take snapshots + const existingElements = getLayoutElements(this.scope) - // Build projection tree for existing elements + // Build projection tree for existing elements FIRST + // This allows the projection system to handle measurements correctly if (existingElements.length > 0) { context = buildProjectionTree( existingElements, @@ -106,17 +107,22 @@ export class LayoutAnimationBuilder implements PromiseLike { // Start update cycle context.root.startUpdate() - // Call willUpdate on all nodes to capture snapshots + // Call willUpdate on all nodes to capture snapshots via projection system + // This handles transforms, scroll, etc. correctly for (const node of context.nodes.values()) { node.willUpdate() } } + // Track DOM structure (parent, sibling) for detecting removals + // No bounds measurement here - projection system already handled that + const beforeRecords = trackLayoutElements(this.scope) + // Phase 2: Execute DOM update this.updateDom() // Phase 3: Post-mutation (Detect & Prepare) - const mutationResult = detectMutations(beforeSnapshots, this.scope) + const mutationResult = detectMutations(beforeRecords, this.scope) // Reattach exiting elements that are NOT part of shared transitions // Shared elements are handled by the projection system via resumeFrom @@ -126,7 +132,7 @@ export class LayoutAnimationBuilder implements PromiseLike { return !layoutId || !mutationResult.sharedEntering.has(layoutId) } ) - this.reattachExitingElements(nonSharedExiting) + this.reattachExitingElements(nonSharedExiting, context) // Build projection nodes for entering elements if (mutationResult.entering.length > 0) { @@ -214,11 +220,18 @@ export class LayoutAnimationBuilder implements PromiseLike { } } - private reattachExitingElements(exiting: RemovedElement[]) { - for (const { element, parentElement, nextSibling, bounds } of exiting) { + private reattachExitingElements(exiting: RemovedElement[], context?: ProjectionContext) { + for (const { element, parentElement, nextSibling } of exiting) { // Check if parent still exists in DOM if (!parentElement.isConnected) continue + // Get bounds from projection node snapshot (measured correctly via projection system) + const node = context?.nodes.get(element) + const snapshot = node?.snapshot + if (!snapshot) continue + + const { layoutBox } = snapshot + // Reattach element if (nextSibling && nextSibling.parentNode === parentElement) { parentElement.insertBefore(element, nextSibling) @@ -227,12 +240,13 @@ export class LayoutAnimationBuilder implements PromiseLike { } // Apply absolute positioning to prevent layout shift + // Use layoutBox from projection system which has transform-free measurements const htmlElement = element as HTMLElement htmlElement.style.position = "absolute" - htmlElement.style.top = `${bounds.top}px` - htmlElement.style.left = `${bounds.left}px` - htmlElement.style.width = `${bounds.width}px` - htmlElement.style.height = `${bounds.height}px` + htmlElement.style.top = `${layoutBox.y.min}px` + htmlElement.style.left = `${layoutBox.x.min}px` + htmlElement.style.width = `${layoutBox.x.max - layoutBox.x.min}px` + htmlElement.style.height = `${layoutBox.y.max - layoutBox.y.min}px` htmlElement.style.margin = "0" htmlElement.style.pointerEvents = "none" } diff --git a/packages/motion-dom/src/layout/detect-mutations.ts b/packages/motion-dom/src/layout/detect-mutations.ts index ab4b4cb4c7..6ed1eab7e8 100644 --- a/packages/motion-dom/src/layout/detect-mutations.ts +++ b/packages/motion-dom/src/layout/detect-mutations.ts @@ -17,45 +17,44 @@ export function hasLayout(element: Element): boolean { ) } -interface ElementSnapshot { +interface ElementRecord { element: HTMLElement parentElement: HTMLElement nextSibling: Node | null - bounds: DOMRect layoutId: string | null } /** - * Snapshot elements before mutation to track removals + * Track layout elements before mutation. + * Does NOT measure bounds - that's handled by the projection system via willUpdate(). */ -export function snapshotElements( +export function trackLayoutElements( scope: Element | Document -): Map { +): Map { const elements = getLayoutElements(scope) - const snapshots = new Map() + const records = new Map() for (const element of elements) { - snapshots.set(element, { + records.set(element, { element, parentElement: element.parentElement as HTMLElement, nextSibling: element.nextSibling, - bounds: element.getBoundingClientRect(), layoutId: getLayoutId(element), }) } - return snapshots + return records } /** - * Compare before/after snapshots to detect entering/exiting/persisting elements + * Compare before/after records to detect entering/exiting/persisting elements */ export function detectMutations( - beforeSnapshots: Map, + beforeRecords: Map, scope: Element | Document ): MutationResult { const afterElements = new Set(getLayoutElements(scope)) - const beforeElements = new Set(beforeSnapshots.keys()) + const beforeElements = new Set(beforeRecords.keys()) const entering: HTMLElement[] = [] const exiting: RemovedElement[] = [] @@ -66,16 +65,15 @@ export function detectMutations( // Find exiting elements (were in before, not in after) for (const element of beforeElements) { if (!afterElements.has(element)) { - const snapshot = beforeSnapshots.get(element)! + const record = beforeRecords.get(element)! exiting.push({ element, - parentElement: snapshot.parentElement, - nextSibling: snapshot.nextSibling, - bounds: snapshot.bounds, + parentElement: record.parentElement, + nextSibling: record.nextSibling, }) - if (snapshot.layoutId) { - sharedExiting.set(snapshot.layoutId, element) + if (record.layoutId) { + sharedExiting.set(record.layoutId, element) } } } diff --git a/packages/motion-dom/src/layout/types.ts b/packages/motion-dom/src/layout/types.ts index 5b97da6442..cb6c34a05d 100644 --- a/packages/motion-dom/src/layout/types.ts +++ b/packages/motion-dom/src/layout/types.ts @@ -7,7 +7,6 @@ export interface RemovedElement { element: HTMLElement parentElement: HTMLElement nextSibling: Node | null - bounds: DOMRect } export interface MutationResult { From d794fb86a0f5d3407ec294a37f164cb1dde93c3f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 06:28:24 +0100 Subject: [PATCH 05/25] v12.27.0-alpha.2 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 424d9cfe6e..39416e11a2 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-alpha.2", "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-alpha.2", + "motion": "^12.27.0-alpha.2", + "motion-dom": "^12.27.0-alpha.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 3d69491ffb..53a5887136 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-alpha.2", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.26.2", + "motion": "^12.27.0-alpha.2", "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..b7d829e349 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-alpha.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.26.2", + "motion": "^12.27.0-alpha.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a8d26058f6..64ee41d199 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-alpha.2", "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-alpha.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index eb0aa44051..387b8009b1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.26.2", + "version": "12.27.0-alpha.2", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4737e7f9d1..624774f6f3 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-alpha.2", "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-alpha.2", "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..8b638ca399 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-alpha.2", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 90e529a5dc..1a7da5a782 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-alpha.2", "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-alpha.2", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 090daab0cf..d835ef264a 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-alpha.2, 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-alpha.2 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-alpha.2 + motion: ^12.27.0-alpha.2 + motion-dom: ^12.27.0-alpha.2 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-alpha.2, 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-alpha.2, 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-alpha.2 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-alpha.2 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-alpha.2 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-alpha.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 8008c4da8c9c58faa3cded6e936a013dd7631c5b Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 06:48:52 +0100 Subject: [PATCH 06/25] Include scope element in layout animation if it has data-layout Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/layout/detect-mutations.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/motion-dom/src/layout/detect-mutations.ts b/packages/motion-dom/src/layout/detect-mutations.ts index 6ed1eab7e8..e578f426ae 100644 --- a/packages/motion-dom/src/layout/detect-mutations.ts +++ b/packages/motion-dom/src/layout/detect-mutations.ts @@ -3,7 +3,14 @@ import type { MutationResult, RemovedElement } from "./types" const LAYOUT_SELECTOR = "[data-layout], [data-layout-id]" export function getLayoutElements(scope: Element | Document): HTMLElement[] { - return Array.from(scope.querySelectorAll(LAYOUT_SELECTOR)) as 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 { From 5e79c9f4de9af935e483f93d6894e348f1709728 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 06:50:00 +0100 Subject: [PATCH 07/25] Add test for scope element with data-layout attribute Co-Authored-By: Claude Opus 4.5 --- .../scope-with-data-layout.html | 71 +++++++++++++++++++ .../fixtures/animate-layout-tests.json | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 dev/html/public/animate-layout/scope-with-data-layout.html 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/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 5919adef9c..75ad6889b2 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","scale-correction.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-multiple-elements.html"] +["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-multiple-elements.html"] From cd9e63f8d963a50097f999212371e903f6b535a6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 06:51:37 +0100 Subject: [PATCH 08/25] Add failing test for repeated layout animations Documents bug where subsequent animateLayout calls on the same element result in instant changes instead of animated transitions. Co-Authored-By: Claude Opus 4.5 --- .../animate-layout/repeat-animation.html | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 dev/html/public/animate-layout/repeat-animation.html 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 @@ + + + + + +
+ + + + + + From f6cfddb358016de7d726dcd66d03f57fc7528b1c Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 10:40:25 +0100 Subject: [PATCH 09/25] Latest --- cypress/plugins/index.js | 21 +++ cypress/support/commands.js | 25 ++++ cypress/support/index.js | 20 +++ .../shared-element-no-crossfade.html | 129 ++++++++++++++++++ .../fixtures/animate-layout-tests.json | 2 +- .../src/layout/LayoutAnimationBuilder.ts | 8 ++ .../motion-dom/src/layout/projection-tree.ts | 9 ++ tests/animate-layout/repeat-animation.spec.ts | 19 +++ .../shared-element-no-crossfade.spec.ts | 18 +++ 9 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 cypress/plugins/index.js create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/index.js create mode 100644 dev/html/public/animate-layout/shared-element-no-crossfade.html create mode 100644 tests/animate-layout/repeat-animation.spec.ts create mode 100644 tests/animate-layout/shared-element-no-crossfade.spec.ts diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..aa9918d215 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,21 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000000..ca4d256f3e --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..d68db96df2 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') 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/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 75ad6889b2..4971328f06 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-multiple-elements.html"] +["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 05982d63b4..1f06f765d4 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -110,6 +110,10 @@ export class LayoutAnimationBuilder implements PromiseLike { // Call willUpdate on all nodes to capture snapshots via projection system // This handles transforms, scroll, etc. correctly for (const node of context.nodes.values()) { + // Reset isLayoutDirty so willUpdate can take a snapshot. + // When hasTreeAnimated is true on the global root, newly mounted nodes + // get isLayoutDirty=true, which causes willUpdate to skip snapshot capture. + node.isLayoutDirty = false node.willUpdate() } } @@ -217,6 +221,10 @@ export class LayoutAnimationBuilder implements PromiseLike { return { defaultTransition: this.defaultOptions || { duration: 0.3, ease: "easeOut" }, sharedTransitions: this.sharedTransitions.size > 0 ? this.sharedTransitions : undefined, + // Disable crossfade by default for animateLayout - shared elements should + // morph position without opacity animation. The old element is removed from + // the DOM, so crossfade would just show a fade-in without corresponding fade-out. + crossfade: false, } } diff --git a/packages/motion-dom/src/layout/projection-tree.ts b/packages/motion-dom/src/layout/projection-tree.ts index 604a6ec347..cd9e385473 100644 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -120,6 +120,13 @@ function createProjectionNode( export interface BuildProjectionTreeOptions { defaultTransition?: AnimationOptions sharedTransitions?: Map + /** + * Whether to enable opacity crossfade for shared element transitions. + * When false (default for animateLayout), shared elements will morph + * position without fading. When true, entering shared elements will + * crossfade with exiting ones. + */ + crossfade?: boolean } /** @@ -139,6 +146,7 @@ export function buildProjectionTree( const defaultTransition = options?.defaultTransition const sharedTransitions = options?.sharedTransitions + const crossfade = options?.crossfade // Sort elements by DOM depth (parents before children) const sorted = [...elements].sort((a, b) => getDepth(a) - getDepth(b)) @@ -156,6 +164,7 @@ export function buildProjectionTree( const nodeOptions: ProjectionNodeOptions = { layoutId: layoutId ?? undefined, animationType: parseLayoutMode(layoutMode), + crossfade, } // Use layoutId-specific transition if available, otherwise use default diff --git a/tests/animate-layout/repeat-animation.spec.ts b/tests/animate-layout/repeat-animation.spec.ts new file mode 100644 index 0000000000..bf76a04231 --- /dev/null +++ b/tests/animate-layout/repeat-animation.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test" + +test.describe("animateLayout", () => { + test("repeat-animation: subsequent animations should animate, not happen instantly", async ({ + page, + }) => { + // Change base URL for this test + await page.goto("http://localhost:8000/animate-layout/repeat-animation.html") + + // Wait for the test to complete + await page.waitForTimeout(500) + + // Check that no elements have data-layout-correct="false" + const incorrectElements = await page.locator( + '[data-layout-correct="false"]' + ) + await expect(incorrectElements).toHaveCount(0) + }) +}) diff --git a/tests/animate-layout/shared-element-no-crossfade.spec.ts b/tests/animate-layout/shared-element-no-crossfade.spec.ts new file mode 100644 index 0000000000..0b926fc8b5 --- /dev/null +++ b/tests/animate-layout/shared-element-no-crossfade.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test" + +test.describe("animateLayout shared elements", () => { + test("shared elements should not fade in by default", async ({ page }) => { + await page.goto( + "http://localhost:8000/animate-layout/shared-element-no-crossfade.html" + ) + + // Wait for the test to complete + await page.waitForTimeout(500) + + // Check that no elements have data-layout-correct="false" + const incorrectElements = await page.locator( + '[data-layout-correct="false"]' + ) + await expect(incorrectElements).toHaveCount(0) + }) +}) From 54a17ce8264d63b63336d320a2437081b351095a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 10:40:49 +0100 Subject: [PATCH 10/25] v12.27.0-alpha.3 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 39416e11a2..f8649898ce 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.2", - "motion": "^12.27.0-alpha.2", - "motion-dom": "^12.27.0-alpha.2", + "framer-motion": "^12.27.0-alpha.3", + "motion": "^12.27.0-alpha.3", + "motion-dom": "^12.27.0-alpha.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 53a5887136..0fc73bd3a9 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.0-alpha.2", + "motion": "^12.27.0-alpha.3", "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 b7d829e349..fb385a9692 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.0-alpha.2", + "motion": "^12.27.0-alpha.3", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 64ee41d199..af75c06048 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.2", + "framer-motion": "^12.27.0-alpha.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 387b8009b1..3ba8c51120 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 624774f6f3..171f6bb629 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.27.0-alpha.2", + "motion-dom": "^12.27.0-alpha.3", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 8b638ca399..686e8ea01f 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 1a7da5a782..4bbebb3fd6 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.0-alpha.2", + "version": "12.27.0-alpha.3", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.2", + "framer-motion": "^12.27.0-alpha.3", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index d835ef264a..3215b9f7a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.0-alpha.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.27.0-alpha.3, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.27.0-alpha.2 + motion-dom: ^12.27.0-alpha.3 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.27.0-alpha.2 - motion: ^12.27.0-alpha.2 - motion-dom: ^12.27.0-alpha.2 + framer-motion: ^12.27.0-alpha.3 + motion: ^12.27.0-alpha.3 + motion-dom: ^12.27.0-alpha.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.27.0-alpha.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.27.0-alpha.3, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.27.0-alpha.2, motion@workspace:packages/motion": +"motion@^12.27.0-alpha.3, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.27.0-alpha.2 + framer-motion: ^12.27.0-alpha.3 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.27.0-alpha.2 + motion: ^12.27.0-alpha.3 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.27.0-alpha.2 + motion: ^12.27.0-alpha.3 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.27.0-alpha.2 + framer-motion: ^12.27.0-alpha.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 2652d3f1a15cb2e3e2887039adf9966f06d55dce Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 11:58:43 +0100 Subject: [PATCH 11/25] Latest --- .../cypress/fixtures/animate-layout-tests.json | 2 +- .../src/layout/LayoutAnimationBuilder.ts | 16 ++++++++++++---- .../motion-dom/src/layout/projection-tree.ts | 9 --------- .../shared-element-crossfade.spec.ts | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 tests/animate-layout/shared-element-crossfade.spec.ts diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 4971328f06..6166662fdc 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation-removed.html","exit-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] +["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 1f06f765d4..f0b8aeadff 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -163,6 +163,18 @@ export class LayoutAnimationBuilder implements PromiseLike { const exitingElement = mutationResult.sharedExiting.get(layoutId) if (exitingElement) { sharedExitingElements.add(exitingElement) + + // Remove the exiting node from the shared stack so that crossfade + // doesn't trigger. When an element is removed from the DOM, it can't + // participate in crossfade (no element to fade out). The entering + // element still has resumeFrom set for position morphing. + const exitingNode = context?.nodes.get(exitingElement) + if (exitingNode) { + const stack = exitingNode.getStack() + if (stack) { + stack.remove(exitingNode) + } + } } } @@ -221,10 +233,6 @@ export class LayoutAnimationBuilder implements PromiseLike { return { defaultTransition: this.defaultOptions || { duration: 0.3, ease: "easeOut" }, sharedTransitions: this.sharedTransitions.size > 0 ? this.sharedTransitions : undefined, - // Disable crossfade by default for animateLayout - shared elements should - // morph position without opacity animation. The old element is removed from - // the DOM, so crossfade would just show a fade-in without corresponding fade-out. - crossfade: false, } } diff --git a/packages/motion-dom/src/layout/projection-tree.ts b/packages/motion-dom/src/layout/projection-tree.ts index cd9e385473..604a6ec347 100644 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -120,13 +120,6 @@ function createProjectionNode( export interface BuildProjectionTreeOptions { defaultTransition?: AnimationOptions sharedTransitions?: Map - /** - * Whether to enable opacity crossfade for shared element transitions. - * When false (default for animateLayout), shared elements will morph - * position without fading. When true, entering shared elements will - * crossfade with exiting ones. - */ - crossfade?: boolean } /** @@ -146,7 +139,6 @@ export function buildProjectionTree( const defaultTransition = options?.defaultTransition const sharedTransitions = options?.sharedTransitions - const crossfade = options?.crossfade // Sort elements by DOM depth (parents before children) const sorted = [...elements].sort((a, b) => getDepth(a) - getDepth(b)) @@ -164,7 +156,6 @@ export function buildProjectionTree( const nodeOptions: ProjectionNodeOptions = { layoutId: layoutId ?? undefined, animationType: parseLayoutMode(layoutMode), - crossfade, } // Use layoutId-specific transition if available, otherwise use default diff --git a/tests/animate-layout/shared-element-crossfade.spec.ts b/tests/animate-layout/shared-element-crossfade.spec.ts new file mode 100644 index 0000000000..a25fcb66e0 --- /dev/null +++ b/tests/animate-layout/shared-element-crossfade.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test" + +test.describe("animateLayout shared elements", () => { + test("crossfade works when both elements are in DOM", async ({ page }) => { + await page.goto( + "http://localhost:8000/animate-layout/shared-element-crossfade.html" + ) + + // Wait for the test to complete + await page.waitForTimeout(500) + + // Check that no elements have data-layout-correct="false" + const incorrectElements = await page.locator( + '[data-layout-correct="false"]' + ) + await expect(incorrectElements).toHaveCount(0) + }) +}) From dcea7a882be9b1b7d3f14c291d9718911e869f53 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 11:59:00 +0100 Subject: [PATCH 12/25] v12.27.0-alpha.4 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index f8649898ce..17347d578f 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.3", - "motion": "^12.27.0-alpha.3", - "motion-dom": "^12.27.0-alpha.3", + "framer-motion": "^12.27.0-alpha.4", + "motion": "^12.27.0-alpha.4", + "motion-dom": "^12.27.0-alpha.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 0fc73bd3a9..dc0899c473 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.0-alpha.3", + "motion": "^12.27.0-alpha.4", "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 fb385a9692..1e9ea7b29e 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.0-alpha.3", + "motion": "^12.27.0-alpha.4", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index af75c06048..def5d33c7b 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.3", + "framer-motion": "^12.27.0-alpha.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 3ba8c51120..f643879e14 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 171f6bb629..29782aec73 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.27.0-alpha.3", + "motion-dom": "^12.27.0-alpha.4", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 686e8ea01f..15e1ca546e 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 4bbebb3fd6..dd3d023ff7 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.0-alpha.3", + "version": "12.27.0-alpha.4", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.3", + "framer-motion": "^12.27.0-alpha.4", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 3215b9f7a6..12e7677d5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.0-alpha.3, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.27.0-alpha.4, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.27.0-alpha.3 + motion-dom: ^12.27.0-alpha.4 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.27.0-alpha.3 - motion: ^12.27.0-alpha.3 - motion-dom: ^12.27.0-alpha.3 + framer-motion: ^12.27.0-alpha.4 + motion: ^12.27.0-alpha.4 + motion-dom: ^12.27.0-alpha.4 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.27.0-alpha.3, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.27.0-alpha.4, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.27.0-alpha.3, motion@workspace:packages/motion": +"motion@^12.27.0-alpha.4, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.27.0-alpha.3 + framer-motion: ^12.27.0-alpha.4 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.27.0-alpha.3 + motion: ^12.27.0-alpha.4 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.27.0-alpha.3 + motion: ^12.27.0-alpha.4 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.27.0-alpha.3 + framer-motion: ^12.27.0-alpha.4 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 331069577f0bf29458972ffea58a2cc1e316cdc9 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 12:55:49 +0100 Subject: [PATCH 13/25] Latest --- .../animate-layout/interrupt-animation.html | 115 ++++++++++++++++++ .../motion-dom/src/layout/projection-tree.ts | 42 ++++++- .../interrupt-animation.spec.ts | 29 +++++ 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 dev/html/public/animate-layout/interrupt-animation.html create mode 100644 tests/animate-layout/interrupt-animation.spec.ts 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/packages/motion-dom/src/layout/projection-tree.ts b/packages/motion-dom/src/layout/projection-tree.ts index 604a6ec347..6beefd1c78 100644 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -13,6 +13,14 @@ 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 @@ -72,7 +80,7 @@ function findProjectionParent( } /** - * Create a single projection node for an element + * Create or reuse a projection node for an element */ function createProjectionNode( element: HTMLElement, @@ -80,6 +88,27 @@ function createProjectionNode( 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, + }) + + return { node: existingNode, visualElement } + } + + // No existing node - create a new one const latestValues: Record = {} const visualElement = new HTMLVisualElement({ @@ -114,6 +143,9 @@ function createProjectionNode( node.mount(element) visualElement.projection = node + // Track this node as the active one for this element + activeProjectionNodes.set(element, node) + return { node, visualElement } } @@ -203,9 +235,15 @@ function parseLayoutMode( * Clean up projection nodes */ export function cleanupProjectionTree(context: ProjectionContext) { - for (const node of context.nodes.values()) { + for (const [element, node] of context.nodes.entries()) { 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.clear() context.visualElements.clear() diff --git a/tests/animate-layout/interrupt-animation.spec.ts b/tests/animate-layout/interrupt-animation.spec.ts new file mode 100644 index 0000000000..208dff4941 --- /dev/null +++ b/tests/animate-layout/interrupt-animation.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test" + +test.describe("animateLayout", () => { + test("interrupted animations should start from visual position, not layout position", async ({ + page, + }) => { + // Capture console logs + const logs: string[] = [] + page.on("console", (msg) => { + logs.push(msg.text()) + }) + + await page.goto( + "http://localhost:8000/animate-layout/interrupt-animation.html" + ) + + // Wait for the test to complete (animation is 0.5s + 0.25s wait + some extra) + await page.waitForTimeout(1500) + + // Print logs for debugging + console.log("Console logs:", logs) + + // Check that no elements have data-layout-correct="false" + const incorrectElements = await page.locator( + '[data-layout-correct="false"]' + ) + await expect(incorrectElements).toHaveCount(0) + }) +}) From a66fa95ab87b2305fa620af7e734b9d5f1cd609e Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 14 Jan 2026 12:56:18 +0100 Subject: [PATCH 14/25] v12.27.0-alpha.5 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 17347d578f..c4754cc8b1 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.4", - "motion": "^12.27.0-alpha.4", - "motion-dom": "^12.27.0-alpha.4", + "framer-motion": "^12.27.0-alpha.5", + "motion": "^12.27.0-alpha.5", + "motion-dom": "^12.27.0-alpha.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index dc0899c473..515ac1f7ed 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.0-alpha.4", + "motion": "^12.27.0-alpha.5", "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 1e9ea7b29e..cd83f29b04 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.0-alpha.4", + "motion": "^12.27.0-alpha.5", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index def5d33c7b..a29ab57793 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.4", + "framer-motion": "^12.27.0-alpha.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index f643879e14..8094df4248 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 29782aec73..670dce8c86 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.27.0-alpha.4", + "motion-dom": "^12.27.0-alpha.5", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 15e1ca546e..acbfb6d2d3 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index dd3d023ff7..787e598ec7 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.0-alpha.4", + "version": "12.27.0-alpha.5", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.4", + "framer-motion": "^12.27.0-alpha.5", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 12e7677d5a..a9c22c5956 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.0-alpha.4, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.27.0-alpha.5, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.27.0-alpha.4 + motion-dom: ^12.27.0-alpha.5 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.27.0-alpha.4 - motion: ^12.27.0-alpha.4 - motion-dom: ^12.27.0-alpha.4 + framer-motion: ^12.27.0-alpha.5 + motion: ^12.27.0-alpha.5 + motion-dom: ^12.27.0-alpha.5 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.27.0-alpha.4, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.27.0-alpha.5, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.27.0-alpha.4, motion@workspace:packages/motion": +"motion@^12.27.0-alpha.5, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.27.0-alpha.4 + framer-motion: ^12.27.0-alpha.5 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.27.0-alpha.4 + motion: ^12.27.0-alpha.5 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.27.0-alpha.4 + motion: ^12.27.0-alpha.5 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.27.0-alpha.4 + framer-motion: ^12.27.0-alpha.5 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From f5ad37ea84701e32644ad486f449797d6be205d4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 16 Jan 2026 06:33:47 +0100 Subject: [PATCH 15/25] Latest --- .../enter-animation-scoped.html | 94 --------- .../animate-layout/enter-animation.html | 88 -------- .../public/animate-layout/exit-animation.html | 117 ----------- .../animate-layout/shared-element-a-ab-a.html | 155 ++++++++++++++ .../shared-element-a-b-a-replace.html | 140 +++++++++++++ .../shared-element-a-b-a-reuse.html | 136 ++++++++++++ dev/html/src/imports/script-assert.js | 8 +- .../fixtures/animate-layout-tests.json | 2 +- .../src/layout/LayoutAnimationBuilder.ts | 194 +++++++----------- .../motion-dom/src/layout/animate-layout.ts | 4 +- .../motion-dom/src/layout/detect-mutations.ts | 40 +--- .../motion-dom/src/layout/projection-tree.ts | 29 ++- packages/motion-dom/src/layout/types.ts | 1 + .../projection/node/create-projection-node.ts | 121 ++++++----- .../shared-element-a-ab-a.spec.ts | 33 +++ 15 files changed, 654 insertions(+), 508 deletions(-) delete mode 100644 dev/html/public/animate-layout/enter-animation-scoped.html delete mode 100644 dev/html/public/animate-layout/enter-animation.html delete mode 100644 dev/html/public/animate-layout/exit-animation.html create mode 100644 dev/html/public/animate-layout/shared-element-a-ab-a.html create mode 100644 dev/html/public/animate-layout/shared-element-a-b-a-replace.html create mode 100644 dev/html/public/animate-layout/shared-element-a-b-a-reuse.html create mode 100644 tests/animate-layout/shared-element-a-ab-a.spec.ts diff --git a/dev/html/public/animate-layout/enter-animation-scoped.html b/dev/html/public/animate-layout/enter-animation-scoped.html deleted file mode 100644 index 88957c77a9..0000000000 --- a/dev/html/public/animate-layout/enter-animation-scoped.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - -
-
- - - - - - diff --git a/dev/html/public/animate-layout/enter-animation.html b/dev/html/public/animate-layout/enter-animation.html deleted file mode 100644 index 299e26b6d9..0000000000 --- a/dev/html/public/animate-layout/enter-animation.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - -
- - - - - - diff --git a/dev/html/public/animate-layout/exit-animation.html b/dev/html/public/animate-layout/exit-animation.html deleted file mode 100644 index 0845f7dbef..0000000000 --- a/dev/html/public/animate-layout/exit-animation.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - - -
-
-
- - - - - - 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/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/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 6166662fdc..4ba3ca5721 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","enter-animation-scoped.html","enter-animation.html","exit-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file +["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-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index f0b8aeadff..5b8748617c 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -1,14 +1,11 @@ import { noop } from "motion-utils" -import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/types" +import type { AnimationOptions } from "../animation/types" import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnimation" -import { animateTarget } from "../animation/interfaces/visual-element-target" -import type { MutationResult, RemovedElement } from "./types" +import type { RemovedElement } from "./types" import { trackLayoutElements, getLayoutElements, detectMutations, - isRootEnteringElement, - isRootExitingElement, } from "./detect-mutations" import { buildProjectionTree, @@ -24,10 +21,6 @@ export class LayoutAnimationBuilder implements PromiseLike { private updateDom: () => void private defaultOptions?: AnimationOptions - private enterKeyframes?: DOMKeyframesDefinition - private enterOptions?: AnimationOptions - private exitKeyframes?: DOMKeyframesDefinition - private exitOptions?: AnimationOptions private sharedTransitions = new Map() private notifyReady: (value: GroupAnimation) => void = noop @@ -51,18 +44,6 @@ export class LayoutAnimationBuilder implements PromiseLike { queueMicrotask(() => this.execute()) } - enter(keyframes: DOMKeyframesDefinition, options?: AnimationOptions): this { - this.enterKeyframes = keyframes - this.enterOptions = options - return this - } - - exit(keyframes: DOMKeyframesDefinition, options?: AnimationOptions): this { - this.exitKeyframes = keyframes - this.exitOptions = options - return this - } - shared( layoutIdOrOptions: string | AnimationOptions, options?: AnimationOptions @@ -128,15 +109,21 @@ export class LayoutAnimationBuilder implements PromiseLike { // Phase 3: Post-mutation (Detect & Prepare) const mutationResult = detectMutations(beforeRecords, this.scope) - // Reattach exiting elements that are NOT part of shared transitions - // Shared elements are handled by the projection system via resumeFrom - const nonSharedExiting = mutationResult.exiting.filter( + // Determine which exiting elements should be reattached: + // Non-shared exiting elements (no layoutId or no matching entering/persisting element) + const exitingToReattach = mutationResult.exiting.filter( ({ element }) => { const layoutId = element.getAttribute("data-layout-id") - return !layoutId || !mutationResult.sharedEntering.has(layoutId) + const isSharedWithEntering = + layoutId && mutationResult.sharedEntering.has(layoutId) + const isSharedWithPersisting = + layoutId && mutationResult.sharedPersisting.has(layoutId) + + // Reattach if not a shared element + return !isSharedWithEntering && !isSharedWithPersisting } ) - this.reattachExitingElements(nonSharedExiting, context) + this.reattachExitingElements(exitingToReattach, context) // Build projection nodes for entering elements if (mutationResult.entering.length > 0) { @@ -156,20 +143,25 @@ export class LayoutAnimationBuilder implements PromiseLike { ) } - // Build set of shared exiting elements to exclude from animation collection - // Their nodes are still in the tree for resumeFrom relationship, but we don't animate them + // Build set of shared exiting elements (they don't animate separately) const sharedExitingElements = new Set() - for (const [layoutId] of mutationResult.sharedEntering) { + + for (const [layoutId, enteringElement] of mutationResult.sharedEntering) { const exitingElement = mutationResult.sharedExiting.get(layoutId) if (exitingElement) { sharedExitingElements.add(exitingElement) - // Remove the exiting node from the shared stack so that crossfade - // doesn't trigger. When an element is removed from the DOM, it can't - // participate in crossfade (no element to fade out). The entering - // element still has resumeFrom set for position morphing. const exitingNode = context?.nodes.get(exitingElement) - if (exitingNode) { + const enteringNode = context?.nodes.get(enteringElement) + + if (exitingNode && enteringNode) { + // Remove exiting node from stack, no crossfade + const stack = exitingNode.getStack() + if (stack) { + stack.remove(exitingNode) + } + } else if (exitingNode && !enteringNode) { + // Fallback: If entering node doesn't exist yet, just handle exiting const stack = exitingNode.getStack() if (stack) { stack.remove(exitingNode) @@ -178,50 +170,67 @@ export class LayoutAnimationBuilder implements PromiseLike { } } + // Handle A -> AB -> A pattern: persisting element should become lead again + // when exiting element with same layoutId is removed + for (const [layoutId, persistingElement] of mutationResult.sharedPersisting) { + const exitingElement = mutationResult.sharedExiting.get(layoutId) + if (!exitingElement) continue + + sharedExitingElements.add(exitingElement) + + const exitingNode = context?.nodes.get(exitingElement) + const persistingNode = context?.nodes.get(persistingElement) + + if (exitingNode && persistingNode) { + // Remove exiting node from stack, no crossfade + const stack = exitingNode.getStack() + if (stack) { + stack.remove(exitingNode) + } + } + } + // Phase 4: Animate if (context) { // Trigger layout animations via didUpdate context.root.didUpdate() // Wait for animations to be created (they're scheduled via frame.update) - await new Promise((resolve) => frame.postRender(() => resolve())) + await new Promise((resolve) => + frame.postRender(() => resolve()) + ) - // Collect layout animations from projection nodes (excluding shared exiting elements) + // Collect layout animations from projection nodes + // Skip shared exiting elements (they don't animate) for (const [element, node] of context.nodes.entries()) { if (sharedExitingElements.has(element)) continue if (node.currentAnimation) { animations.push(node.currentAnimation) } } - - // Apply enter keyframes to root entering elements - if (this.enterKeyframes) { - const enterAnimations = this.animateEntering(mutationResult, context) - animations.push(...enterAnimations) - } - - // Apply exit keyframes to root exiting elements - if (this.exitKeyframes) { - const exitAnimations = this.animateExiting(mutationResult, context) - animations.push(...exitAnimations) - } } // Create and return group animation const groupAnimation = new GroupAnimation(animations) // Phase 5: Setup cleanup on complete + // Only cleanup exiting elements - persisting elements keep their nodes + // This matches React's behavior where nodes persist for elements in the DOM + const exitingElements = new Set( + mutationResult.exiting.map(({ element }) => element) + ) groupAnimation.finished.then(() => { - // Only clean up non-shared exiting elements (those we reattached) - this.cleanupExitingElements(nonSharedExiting) + // Clean up all reattached exiting elements (remove from DOM) + this.cleanupExitingElements(exitingToReattach) if (context) { - cleanupProjectionTree(context) + // Only cleanup projection nodes for exiting elements + cleanupProjectionTree(context, exitingElements) } }) this.notifyReady(groupAnimation) } catch (error) { - // Cleanup on error + // Cleanup on error - cleanup all nodes since animation failed if (context) { cleanupProjectionTree(context) } @@ -231,12 +240,21 @@ export class LayoutAnimationBuilder implements PromiseLike { private getBuildOptions(): BuildProjectionTreeOptions { return { - defaultTransition: this.defaultOptions || { duration: 0.3, ease: "easeOut" }, - sharedTransitions: this.sharedTransitions.size > 0 ? this.sharedTransitions : undefined, + defaultTransition: this.defaultOptions || { + duration: 0.3, + ease: "easeOut", + }, + sharedTransitions: + this.sharedTransitions.size > 0 + ? this.sharedTransitions + : undefined, } } - private reattachExitingElements(exiting: RemovedElement[], context?: ProjectionContext) { + private reattachExitingElements( + exiting: RemovedElement[], + context?: ProjectionContext + ) { for (const { element, parentElement, nextSibling } of exiting) { // Check if parent still exists in DOM if (!parentElement.isConnected) continue @@ -275,72 +293,6 @@ export class LayoutAnimationBuilder implements PromiseLike { } } } - - private animateEntering( - mutationResult: MutationResult, - context: ProjectionContext - ): AcceptedAnimations[] { - const enteringSet = new Set(mutationResult.entering) - - // Find root entering elements - const rootEntering = mutationResult.entering.filter((el) => - isRootEnteringElement(el, enteringSet) - ) - - const animations: AcceptedAnimations[] = [] - - for (const element of rootEntering) { - const visualElement = context.visualElements.get(element) - if (!visualElement) continue - - // If entering with opacity: 1, start from opacity: 0 - const keyframes = { ...this.enterKeyframes } - if (keyframes.opacity !== undefined) { - const targetOpacity = Array.isArray(keyframes.opacity) - ? keyframes.opacity[keyframes.opacity.length - 1] - : keyframes.opacity - - if (targetOpacity === 1) { - ;(element as HTMLElement).style.opacity = "0" - } - } - - const options = this.enterOptions || this.defaultOptions || {} - const enterAnims = animateTarget(visualElement, keyframes as any, { - transitionOverride: options as any, - }) - animations.push(...enterAnims) - } - - return animations - } - - private animateExiting( - mutationResult: MutationResult, - context: ProjectionContext - ): AcceptedAnimations[] { - const exitingSet = new Set(mutationResult.exiting.map((r) => r.element)) - - // Find root exiting elements - const rootExiting = mutationResult.exiting.filter((r) => - isRootExitingElement(r.element, exitingSet) - ) - - const animations: AcceptedAnimations[] = [] - - for (const { element } of rootExiting) { - const visualElement = context.visualElements.get(element) - if (!visualElement) continue - - const options = this.exitOptions || this.defaultOptions || {} - const exitAnims = animateTarget(visualElement, this.exitKeyframes as any, { - transitionOverride: options as any, - }) - animations.push(...exitAnims) - } - - return animations - } } /** diff --git a/packages/motion-dom/src/layout/animate-layout.ts b/packages/motion-dom/src/layout/animate-layout.ts index 314280718e..57e46c0c43 100644 --- a/packages/motion-dom/src/layout/animate-layout.ts +++ b/packages/motion-dom/src/layout/animate-layout.ts @@ -23,12 +23,10 @@ import { * // With options * await animateLayout(() => update(), { duration: 0.5 }) * - * // Builder pattern for enter/exit animations + * // Configure shared element transitions * animateLayout(".cards", () => { * container.innerHTML = newCards * }, { duration: 0.3 }) - * .enter({ opacity: 1, scale: 1 }, { duration: 0.2 }) - * .exit({ opacity: 0, scale: 0.8 }) * .shared("hero", { type: "spring" }) * ``` * diff --git a/packages/motion-dom/src/layout/detect-mutations.ts b/packages/motion-dom/src/layout/detect-mutations.ts index e578f426ae..b23beca4ef 100644 --- a/packages/motion-dom/src/layout/detect-mutations.ts +++ b/packages/motion-dom/src/layout/detect-mutations.ts @@ -68,6 +68,7 @@ export function detectMutations( const persisting: HTMLElement[] = [] const sharedEntering = new Map() const sharedExiting = new Map() + const sharedPersisting = new Map() // Find exiting elements (were in before, not in after) for (const element of beforeElements) { @@ -98,41 +99,22 @@ export function detectMutations( } } + // Find persisting elements that share a layoutId with exiting elements + // This handles the A -> AB -> A pattern where A persists and B exits + for (const element of persisting) { + const layoutId = getLayoutId(element) + if (layoutId && sharedExiting.has(layoutId)) { + sharedPersisting.set(layoutId, element) + } + } + return { entering, exiting, persisting, sharedEntering, sharedExiting, + sharedPersisting, } } -/** - * Check if an element is a "root" entering element (no entering ancestors) - */ -export function isRootEnteringElement( - element: Element, - allEntering: Set -): boolean { - let parent = element.parentElement - while (parent) { - if (allEntering.has(parent)) return false - parent = parent.parentElement - } - return true -} - -/** - * Check if an element is a "root" exiting element (no exiting ancestors) - */ -export function isRootExitingElement( - element: Element, - allExiting: Set -): boolean { - let parent = element.parentElement - while (parent) { - if (allExiting.has(parent)) return false - parent = parent.parentElement - } - return true -} diff --git a/packages/motion-dom/src/layout/projection-tree.ts b/packages/motion-dom/src/layout/projection-tree.ts index 6beefd1c78..522d206ad3 100644 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -105,6 +105,12 @@ function createProjectionNode( ...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 } } @@ -232,10 +238,22 @@ function parseLayoutMode( } /** - * Clean up projection nodes + * 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) { - for (const [element, node] of context.nodes.entries()) { +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() @@ -244,9 +262,10 @@ export function cleanupProjectionTree(context: ProjectionContext) { if (activeProjectionNodes.get(element) === node) { activeProjectionNodes.delete(element) } + + context.nodes.delete(element) + context.visualElements.delete(element) } - context.nodes.clear() - context.visualElements.clear() } /** diff --git a/packages/motion-dom/src/layout/types.ts b/packages/motion-dom/src/layout/types.ts index cb6c34a05d..85384edff0 100644 --- a/packages/motion-dom/src/layout/types.ts +++ b/packages/motion-dom/src/layout/types.ts @@ -15,4 +15,5 @@ export interface MutationResult { persisting: HTMLElement[] sharedEntering: Map sharedExiting: Map + sharedPersisting: Map } 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..01fe6f1f70 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -367,6 +367,13 @@ export function createProjectionNode({ */ resumingFrom?: IProjectionNode + /** + * Whether this element is present in the DOM. Used to determine if an element + * is exiting (isPresent=false) and should have its animation controlled by + * the lead element that is resuming from it. + */ + isPresent?: boolean + /** * A reference to the element's latest animated values. This is a reference shared * between the element's VisualElement and the ProjectionNode. @@ -505,6 +512,14 @@ export function createProjectionNode({ return } + /** + * Check if this is a follow element in a crossfade (exiting element + * whose lead has resumeFrom pointing to it). These elements should + * NOT create their own animation - they get the lead's animation via + * resumingFrom.currentAnimation in startAnimation(). + */ + const isFollowInCrossfade = this.isPresent === false && !this.isLead() + // TODO: Check here if an animation exists const layoutTransition = this.options.transition || @@ -538,57 +553,65 @@ export function createProjectionNode({ const hasOnlyRelativeTargetChanged = !hasLayoutChanged && hasRelativeLayoutChanged - if ( - this.options.layoutRoot || - this.resumeFrom || - hasOnlyRelativeTargetChanged || - (hasLayoutChanged && - (hasTargetChanged || !this.currentAnimation)) - ) { - if (this.resumeFrom) { - this.resumingFrom = this.resumeFrom - this.resumingFrom.resumingFrom = undefined - } - - const animationOptions = { - ...getValueTransition( - layoutTransition, - "layout" - ), - onPlay: onLayoutAnimationStart, - onComplete: onLayoutAnimationComplete, - } - + /** + * Skip animation handling for follow elements in a crossfade. + * These elements get the lead's animation via resumingFrom.currentAnimation + * in startAnimation(), so they shouldn't create their own animation. + * They also shouldn't call finishAnimation(), which would reset their state. + */ + if (!isFollowInCrossfade) { if ( - visualElement.shouldReduceMotion || - this.options.layoutRoot + this.options.layoutRoot || + this.resumeFrom || + hasOnlyRelativeTargetChanged || + (hasLayoutChanged && + (hasTargetChanged || !this.currentAnimation)) ) { - animationOptions.delay = 0 - animationOptions.type = false - } - - this.startAnimation(animationOptions) - /** - * Set animation origin after starting animation to avoid layout jump - * caused by stopping previous layout animation - */ - this.setAnimationOrigin( - delta, - hasOnlyRelativeTargetChanged - ) - } else { - /** - * If the layout hasn't changed and we have an animation that hasn't started yet, - * finish it immediately. Otherwise it will be animating from a location - * that was probably never committed to screen and look like a jumpy box. - */ - - if (!hasLayoutChanged) { - finishAnimation(this) - } - - if (this.isLead() && this.options.onExitComplete) { - this.options.onExitComplete() + if (this.resumeFrom) { + this.resumingFrom = this.resumeFrom + this.resumingFrom.resumingFrom = undefined + } + + const animationOptions = { + ...getValueTransition( + layoutTransition, + "layout" + ), + onPlay: onLayoutAnimationStart, + onComplete: onLayoutAnimationComplete, + } + + if ( + visualElement.shouldReduceMotion || + this.options.layoutRoot + ) { + animationOptions.delay = 0 + animationOptions.type = false + } + + this.startAnimation(animationOptions) + /** + * Set animation origin after starting animation to avoid layout jump + * caused by stopping previous layout animation + */ + this.setAnimationOrigin( + delta, + hasOnlyRelativeTargetChanged + ) + } else { + /** + * If the layout hasn't changed and we have an animation that hasn't started yet, + * finish it immediately. Otherwise it will be animating from a location + * that was probably never committed to screen and look like a jumpy box. + */ + + if (!hasLayoutChanged) { + finishAnimation(this) + } + + if (this.isLead() && this.options.onExitComplete) { + this.options.onExitComplete() + } } } diff --git a/tests/animate-layout/shared-element-a-ab-a.spec.ts b/tests/animate-layout/shared-element-a-ab-a.spec.ts new file mode 100644 index 0000000000..2c4a112ac8 --- /dev/null +++ b/tests/animate-layout/shared-element-a-ab-a.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test" + +test.describe("animateLayout shared elements A-AB-A", () => { + test("crossfade works for A -> AB -> A pattern", async ({ page }) => { + // Capture console errors + const errors: string[] = [] + page.on('console', msg => { + errors.push(`[${msg.type()}] ${msg.text()}`) + }) + + await page.goto( + "http://localhost:8000/animate-layout/shared-element-a-ab-a.html" + ) + + // Wait for the test to complete + await page.waitForTimeout(2000) + + // Log all console messages for debugging + console.log('All console:', errors) + + // Also get error messages from the page + const pageErrors = await page.locator('p').allTextContents() + if (pageErrors.length > 0) { + console.log('Page errors:', pageErrors) + } + + // Check that no elements have data-layout-correct="false" + const incorrectElements = await page.locator( + '[data-layout-correct="false"]' + ) + await expect(incorrectElements).toHaveCount(0) + }) +}) From 1e3d45991b3a1b17214bf816feb632a2c2fe5f75 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 16 Jan 2026 07:15:32 +0100 Subject: [PATCH 16/25] v12.27.0-alpha.6 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index c4754cc8b1..43cd0cd80c 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.5", - "motion": "^12.27.0-alpha.5", - "motion-dom": "^12.27.0-alpha.5", + "framer-motion": "^12.27.0-alpha.6", + "motion": "^12.27.0-alpha.6", + "motion-dom": "^12.27.0-alpha.6", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 515ac1f7ed..9546b60804 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.0-alpha.5", + "motion": "^12.27.0-alpha.6", "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 cd83f29b04..8107ac81b1 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.0-alpha.5", + "motion": "^12.27.0-alpha.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a29ab57793..222dc5253e 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.5", + "framer-motion": "^12.27.0-alpha.6", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 8094df4248..82cd957df1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 670dce8c86..265811d0a2 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.27.0-alpha.5", + "motion-dom": "^12.27.0-alpha.6", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index acbfb6d2d3..27de6b62c7 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 787e598ec7..db0aef925d 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.0-alpha.5", + "version": "12.27.0-alpha.6", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.5", + "framer-motion": "^12.27.0-alpha.6", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index a9c22c5956..d96a58dc92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.0-alpha.5, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.27.0-alpha.6, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.27.0-alpha.5 + motion-dom: ^12.27.0-alpha.6 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.27.0-alpha.5 - motion: ^12.27.0-alpha.5 - motion-dom: ^12.27.0-alpha.5 + framer-motion: ^12.27.0-alpha.6 + motion: ^12.27.0-alpha.6 + motion-dom: ^12.27.0-alpha.6 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.27.0-alpha.5, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.27.0-alpha.6, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.27.0-alpha.5, motion@workspace:packages/motion": +"motion@^12.27.0-alpha.6, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.27.0-alpha.5 + framer-motion: ^12.27.0-alpha.6 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.27.0-alpha.5 + motion: ^12.27.0-alpha.6 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.27.0-alpha.5 + motion: ^12.27.0-alpha.6 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.27.0-alpha.5 + framer-motion: ^12.27.0-alpha.6 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From cfa83ebee53c4bb8bbb21f2924e026f911688459 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 16 Jan 2026 12:47:19 +0100 Subject: [PATCH 17/25] Latest --- .../shared-element-app-store.html | 485 ++++++++++++++++++ ...shared-element-nested-children-bottom.html | 416 +++++++++++++++ .../shared-element-nested-children.html | 449 ++++++++++++++++ packages/motion-dom/src/index.ts | 2 +- 4 files changed, 1351 insertions(+), 1 deletion(-) create mode 100644 dev/html/public/animate-layout/shared-element-app-store.html create mode 100644 dev/html/public/animate-layout/shared-element-nested-children-bottom.html create mode 100644 dev/html/public/animate-layout/shared-element-nested-children.html 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

    +
    +
    +
  • + + +
  • +
    +
    +
    +
    +
    +

    Steps

    +

    Card D Title

    +
    +
    +
  • + + +
  • +
    +
    +
    +
    +
    +

    Hats

    +

    Card B Title

    +
    +
    +
  • +
+
+ + + + + + 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/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index d599d652bd..081ad72416 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -272,7 +272,7 @@ export { camelToDash } from "./render/dom/utils/camel-to-dash" * Layout animations */ export { unstable_animateLayout } from "./layout/animate-layout" -export { LayoutAnimationBuilder } from "./layout/LayoutAnimationBuilder" +export { LayoutAnimationBuilder, parseAnimateLayoutArgs } from "./layout/LayoutAnimationBuilder" /** * Deprecated From b78fced56c1c9591bf0c70de3531ec3a83ce1046 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 13:52:32 +0100 Subject: [PATCH 18/25] Latest --- cypress/plugins/index.js | 21 --------------------- cypress/support/commands.js | 25 ------------------------- cypress/support/index.js | 20 -------------------- 3 files changed, 66 deletions(-) delete mode 100644 cypress/plugins/index.js delete mode 100644 cypress/support/commands.js delete mode 100644 cypress/support/index.js diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js deleted file mode 100644 index aa9918d215..0000000000 --- a/cypress/plugins/index.js +++ /dev/null @@ -1,21 +0,0 @@ -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -} diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index ca4d256f3e..0000000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/cypress/support/index.js b/cypress/support/index.js deleted file mode 100644 index d68db96df2..0000000000 --- a/cypress/support/index.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') From 43407f3dbff05daa9da830025dc2754b15066fdc Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 13:57:35 +0100 Subject: [PATCH 19/25] Latest --- dev/html/src/imports/animate-layout.js | 16 +- packages/motion-dom/src/index.ts | 162 +++++++++++------- .../motion-dom/src/layout/animate-layout.ts | 54 ------ 3 files changed, 114 insertions(+), 118 deletions(-) delete mode 100644 packages/motion-dom/src/layout/animate-layout.ts diff --git a/dev/html/src/imports/animate-layout.js b/dev/html/src/imports/animate-layout.js index 58b2ec1ced..484c5f71f0 100644 --- a/dev/html/src/imports/animate-layout.js +++ b/dev/html/src/imports/animate-layout.js @@ -1,9 +1,23 @@ import { - unstable_animateLayout, 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, diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 081ad72416..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,132 +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 { unstable_animateLayout } from "./layout/animate-layout" -export { LayoutAnimationBuilder, parseAnimateLayoutArgs } from "./layout/LayoutAnimationBuilder" +export { + LayoutAnimationBuilder, + parseAnimateLayoutArgs, +} from "./layout/LayoutAnimationBuilder" /** * Deprecated diff --git a/packages/motion-dom/src/layout/animate-layout.ts b/packages/motion-dom/src/layout/animate-layout.ts deleted file mode 100644 index 57e46c0c43..0000000000 --- a/packages/motion-dom/src/layout/animate-layout.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AnimationOptions } from "../animation/types" -import type { ElementOrSelector } from "../utils/resolve-elements" -import { - LayoutAnimationBuilder, - parseAnimateLayoutArgs, -} from "./LayoutAnimationBuilder" - -/** - * Animate layout changes within a DOM tree. - * - * @example - * ```typescript - * // Basic usage - animates all elements with data-layout or data-layout-id - * await animateLayout(() => { - * container.innerHTML = newContent - * }) - * - * // With scope - only animates within the container - * await animateLayout(".container", () => { - * updateContent() - * }) - * - * // With options - * await animateLayout(() => update(), { duration: 0.5 }) - * - * // Configure shared element transitions - * animateLayout(".cards", () => { - * container.innerHTML = newCards - * }, { duration: 0.3 }) - * .shared("hero", { type: "spring" }) - * ``` - * - * Elements are animated if they have: - * - `data-layout` attribute (layout animation only) - * - `data-layout-id` attribute (shared element transitions) - * - * @param scopeOrUpdateDom - Either a scope selector/element, or the DOM update function - * @param updateDomOrOptions - Either the DOM update function or animation options - * @param options - Animation options (when scope is provided) - * @returns A builder that resolves to animation controls - */ -export function unstable_animateLayout( - scopeOrUpdateDom: ElementOrSelector | (() => void), - updateDomOrOptions?: (() => void) | AnimationOptions, - options?: AnimationOptions -): LayoutAnimationBuilder { - const { scope, updateDom, defaultOptions } = parseAnimateLayoutArgs( - scopeOrUpdateDom, - updateDomOrOptions, - options - ) - - return new LayoutAnimationBuilder(scope, updateDom, defaultOptions) -} From df20720ec309296edbfdaf451c6315c86fed6297 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 14:11:25 +0100 Subject: [PATCH 20/25] Latest --- .../fixtures/animate-layout-tests.json | 2 +- .../src/layout/LayoutAnimationBuilder.ts | 187 +++--------------- 2 files changed, 32 insertions(+), 157 deletions(-) diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 4ba3ca5721..ede6381074 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["basic-position-change.html","interrupt-animation.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file +["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/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 5b8748617c..7de7c6330b 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -1,12 +1,7 @@ import { noop } from "motion-utils" import type { AnimationOptions } from "../animation/types" import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnimation" -import type { RemovedElement } from "./types" -import { - trackLayoutElements, - getLayoutElements, - detectMutations, -} from "./detect-mutations" +import { getLayoutElements } from "./detect-mutations" import { buildProjectionTree, cleanupProjectionTree, @@ -44,15 +39,8 @@ export class LayoutAnimationBuilder implements PromiseLike { queueMicrotask(() => this.execute()) } - shared( - layoutIdOrOptions: string | AnimationOptions, - options?: AnimationOptions - ): this { - if (typeof layoutIdOrOptions === "string") { - this.sharedTransitions.set(layoutIdOrOptions, options!) - } - // For now, we ignore default shared options as the projection system - // handles shared transitions automatically + shared(id: string, options: AnimationOptions): this { + this.sharedTransitions.set(id, options) return this } @@ -74,163 +62,92 @@ export class LayoutAnimationBuilder implements PromiseLike { try { // Phase 1: Pre-mutation - Build projection tree and take snapshots - const existingElements = getLayoutElements(this.scope) + const beforeElements = getLayoutElements(this.scope) - // Build projection tree for existing elements FIRST - // This allows the projection system to handle measurements correctly - if (existingElements.length > 0) { + if (beforeElements.length > 0) { context = buildProjectionTree( - existingElements, + beforeElements, undefined, this.getBuildOptions() ) - // Start update cycle context.root.startUpdate() - // Call willUpdate on all nodes to capture snapshots via projection system - // This handles transforms, scroll, etc. correctly for (const node of context.nodes.values()) { - // Reset isLayoutDirty so willUpdate can take a snapshot. - // When hasTreeAnimated is true on the global root, newly mounted nodes - // get isLayoutDirty=true, which causes willUpdate to skip snapshot capture. node.isLayoutDirty = false node.willUpdate() } } - // Track DOM structure (parent, sibling) for detecting removals - // No bounds measurement here - projection system already handled that - const beforeRecords = trackLayoutElements(this.scope) - // Phase 2: Execute DOM update this.updateDom() - // Phase 3: Post-mutation (Detect & Prepare) - const mutationResult = detectMutations(beforeRecords, this.scope) + // Phase 3: Post-mutation - Compare before/after elements + const afterElements = getLayoutElements(this.scope) + const beforeSet = new Set(beforeElements) + const afterSet = new Set(afterElements) - // Determine which exiting elements should be reattached: - // Non-shared exiting elements (no layoutId or no matching entering/persisting element) - const exitingToReattach = mutationResult.exiting.filter( - ({ element }) => { - const layoutId = element.getAttribute("data-layout-id") - const isSharedWithEntering = - layoutId && mutationResult.sharedEntering.has(layoutId) - const isSharedWithPersisting = - layoutId && mutationResult.sharedPersisting.has(layoutId) - - // Reattach if not a shared element - return !isSharedWithEntering && !isSharedWithPersisting - } - ) - this.reattachExitingElements(exitingToReattach, context) + const entering = afterElements.filter((el) => !beforeSet.has(el)) + const exiting = beforeElements.filter((el) => !afterSet.has(el)) // Build projection nodes for entering elements - if (mutationResult.entering.length > 0) { + if (entering.length > 0) { context = buildProjectionTree( - mutationResult.entering, + entering, context, this.getBuildOptions() ) } - // Also ensure persisting elements have nodes if context didn't exist - if (!context && mutationResult.persisting.length > 0) { - context = buildProjectionTree( - mutationResult.persisting, - undefined, - this.getBuildOptions() - ) - } - - // Build set of shared exiting elements (they don't animate separately) - const sharedExitingElements = new Set() - - for (const [layoutId, enteringElement] of mutationResult.sharedEntering) { - const exitingElement = mutationResult.sharedExiting.get(layoutId) - if (exitingElement) { - sharedExitingElements.add(exitingElement) - - const exitingNode = context?.nodes.get(exitingElement) - const enteringNode = context?.nodes.get(enteringElement) - - if (exitingNode && enteringNode) { - // Remove exiting node from stack, no crossfade - const stack = exitingNode.getStack() - if (stack) { - stack.remove(exitingNode) - } - } else if (exitingNode && !enteringNode) { - // Fallback: If entering node doesn't exist yet, just handle exiting - const stack = exitingNode.getStack() + // Handle presence changes for shared elements + if (context) { + // Remove exiting elements from stack (no exit animation supported) + for (const element of exiting) { + const node = context.nodes.get(element) + if (node) { + node.isPresent = false + const stack = node.getStack() if (stack) { - stack.remove(exitingNode) + stack.remove(node) } } } - } - - // Handle A -> AB -> A pattern: persisting element should become lead again - // when exiting element with same layoutId is removed - for (const [layoutId, persistingElement] of mutationResult.sharedPersisting) { - const exitingElement = mutationResult.sharedExiting.get(layoutId) - if (!exitingElement) continue - - sharedExitingElements.add(exitingElement) - const exitingNode = context?.nodes.get(exitingElement) - const persistingNode = context?.nodes.get(persistingElement) - - if (exitingNode && persistingNode) { - // Remove exiting node from stack, no crossfade - const stack = exitingNode.getStack() - if (stack) { - stack.remove(exitingNode) + // Mark entering elements as present and promote + for (const element of entering) { + const node = context.nodes.get(element) + if (node) { + node.isPresent = true + node.promote() } } } // Phase 4: Animate if (context) { - // Trigger layout animations via didUpdate context.root.didUpdate() - // Wait for animations to be created (they're scheduled via frame.update) await new Promise((resolve) => frame.postRender(() => resolve()) ) - // Collect layout animations from projection nodes - // Skip shared exiting elements (they don't animate) - for (const [element, node] of context.nodes.entries()) { - if (sharedExitingElements.has(element)) continue + for (const node of context.nodes.values()) { if (node.currentAnimation) { animations.push(node.currentAnimation) } } } - // Create and return group animation const groupAnimation = new GroupAnimation(animations) - // Phase 5: Setup cleanup on complete - // Only cleanup exiting elements - persisting elements keep their nodes - // This matches React's behavior where nodes persist for elements in the DOM - const exitingElements = new Set( - mutationResult.exiting.map(({ element }) => element) - ) groupAnimation.finished.then(() => { - // Clean up all reattached exiting elements (remove from DOM) - this.cleanupExitingElements(exitingToReattach) if (context) { - // Only cleanup projection nodes for exiting elements - cleanupProjectionTree(context, exitingElements) + cleanupProjectionTree(context) } }) this.notifyReady(groupAnimation) } catch (error) { - // Cleanup on error - cleanup all nodes since animation failed if (context) { cleanupProjectionTree(context) } @@ -251,48 +168,6 @@ export class LayoutAnimationBuilder implements PromiseLike { } } - private reattachExitingElements( - exiting: RemovedElement[], - context?: ProjectionContext - ) { - for (const { element, parentElement, nextSibling } of exiting) { - // Check if parent still exists in DOM - if (!parentElement.isConnected) continue - - // Get bounds from projection node snapshot (measured correctly via projection system) - const node = context?.nodes.get(element) - const snapshot = node?.snapshot - if (!snapshot) continue - - const { layoutBox } = snapshot - - // Reattach element - if (nextSibling && nextSibling.parentNode === parentElement) { - parentElement.insertBefore(element, nextSibling) - } else { - parentElement.appendChild(element) - } - - // Apply absolute positioning to prevent layout shift - // Use layoutBox from projection system which has transform-free measurements - const htmlElement = element as HTMLElement - htmlElement.style.position = "absolute" - htmlElement.style.top = `${layoutBox.y.min}px` - htmlElement.style.left = `${layoutBox.x.min}px` - htmlElement.style.width = `${layoutBox.x.max - layoutBox.x.min}px` - htmlElement.style.height = `${layoutBox.y.max - layoutBox.y.min}px` - htmlElement.style.margin = "0" - htmlElement.style.pointerEvents = "none" - } - } - - private cleanupExitingElements(exiting: RemovedElement[]) { - for (const { element } of exiting) { - if (element.parentElement) { - element.parentElement.removeChild(element) - } - } - } } /** From 6a5c6f7ced7bee7003671daada45a744a3e1da20 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 14:19:05 +0100 Subject: [PATCH 21/25] Latest --- .../src/layout/LayoutAnimationBuilder.ts | 20 +-- .../motion-dom/src/layout/detect-mutations.ts | 120 ----------------- .../src/layout/get-layout-elements.ts | 23 ++++ .../motion-dom/src/layout/projection-tree.ts | 2 +- packages/motion-dom/src/layout/types.ts | 15 --- .../projection/node/create-projection-node.ts | 121 +++++++----------- 6 files changed, 80 insertions(+), 221 deletions(-) delete mode 100644 packages/motion-dom/src/layout/detect-mutations.ts create mode 100644 packages/motion-dom/src/layout/get-layout-elements.ts diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 7de7c6330b..f04d8ca6b1 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -1,7 +1,7 @@ import { noop } from "motion-utils" import type { AnimationOptions } from "../animation/types" import { GroupAnimation, type AcceptedAnimations } from "../animation/GroupAnimation" -import { getLayoutElements } from "./detect-mutations" +import { getLayoutElements } from "./get-layout-elements" import { buildProjectionTree, cleanupProjectionTree, @@ -99,27 +99,21 @@ export class LayoutAnimationBuilder implements PromiseLike { ) } - // Handle presence changes for shared elements + // Handle shared elements if (context) { // Remove exiting elements from stack (no exit animation supported) for (const element of exiting) { const node = context.nodes.get(element) - if (node) { - node.isPresent = false - const stack = node.getStack() - if (stack) { - stack.remove(node) - } + const stack = node?.getStack() + if (stack) { + stack.remove(node) } } - // Mark entering elements as present and promote + // Promote entering elements to become lead for (const element of entering) { const node = context.nodes.get(element) - if (node) { - node.isPresent = true - node.promote() - } + node?.promote() } } diff --git a/packages/motion-dom/src/layout/detect-mutations.ts b/packages/motion-dom/src/layout/detect-mutations.ts deleted file mode 100644 index b23beca4ef..0000000000 --- a/packages/motion-dom/src/layout/detect-mutations.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { MutationResult, RemovedElement } from "./types" - -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") -} - -export function hasLayout(element: Element): boolean { - return ( - element.hasAttribute("data-layout") || - element.hasAttribute("data-layout-id") - ) -} - -interface ElementRecord { - element: HTMLElement - parentElement: HTMLElement - nextSibling: Node | null - layoutId: string | null -} - -/** - * Track layout elements before mutation. - * Does NOT measure bounds - that's handled by the projection system via willUpdate(). - */ -export function trackLayoutElements( - scope: Element | Document -): Map { - const elements = getLayoutElements(scope) - const records = new Map() - - for (const element of elements) { - records.set(element, { - element, - parentElement: element.parentElement as HTMLElement, - nextSibling: element.nextSibling, - layoutId: getLayoutId(element), - }) - } - - return records -} - -/** - * Compare before/after records to detect entering/exiting/persisting elements - */ -export function detectMutations( - beforeRecords: Map, - scope: Element | Document -): MutationResult { - const afterElements = new Set(getLayoutElements(scope)) - const beforeElements = new Set(beforeRecords.keys()) - - const entering: HTMLElement[] = [] - const exiting: RemovedElement[] = [] - const persisting: HTMLElement[] = [] - const sharedEntering = new Map() - const sharedExiting = new Map() - const sharedPersisting = new Map() - - // Find exiting elements (were in before, not in after) - for (const element of beforeElements) { - if (!afterElements.has(element)) { - const record = beforeRecords.get(element)! - exiting.push({ - element, - parentElement: record.parentElement, - nextSibling: record.nextSibling, - }) - - if (record.layoutId) { - sharedExiting.set(record.layoutId, element) - } - } - } - - // Find entering and persisting elements - for (const element of afterElements) { - if (!beforeElements.has(element)) { - entering.push(element) - const layoutId = getLayoutId(element) - if (layoutId) { - sharedEntering.set(layoutId, element) - } - } else { - persisting.push(element) - } - } - - // Find persisting elements that share a layoutId with exiting elements - // This handles the A -> AB -> A pattern where A persists and B exits - for (const element of persisting) { - const layoutId = getLayoutId(element) - if (layoutId && sharedExiting.has(layoutId)) { - sharedPersisting.set(layoutId, element) - } - } - - return { - entering, - exiting, - persisting, - sharedEntering, - sharedExiting, - sharedPersisting, - } -} - 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 index 522d206ad3..2b9705cb88 100644 --- a/packages/motion-dom/src/layout/projection-tree.ts +++ b/packages/motion-dom/src/layout/projection-tree.ts @@ -6,7 +6,7 @@ import type { import { HTMLProjectionNode } from "../projection/node/HTMLProjectionNode" import { HTMLVisualElement } from "../render/html/HTMLVisualElement" import { nodeGroup, type NodeGroup } from "../projection/node/group" -import { getLayoutId } from "./detect-mutations" +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" diff --git a/packages/motion-dom/src/layout/types.ts b/packages/motion-dom/src/layout/types.ts index 85384edff0..8d4c09bebc 100644 --- a/packages/motion-dom/src/layout/types.ts +++ b/packages/motion-dom/src/layout/types.ts @@ -2,18 +2,3 @@ import type { AnimationOptions, DOMKeyframesDefinition } from "../animation/type import type { ElementOrSelector } from "../utils/resolve-elements" export type { AnimationOptions, DOMKeyframesDefinition, ElementOrSelector } - -export interface RemovedElement { - element: HTMLElement - parentElement: HTMLElement - nextSibling: Node | null -} - -export interface MutationResult { - entering: HTMLElement[] - exiting: RemovedElement[] - persisting: HTMLElement[] - sharedEntering: Map - sharedExiting: Map - sharedPersisting: Map -} 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 01fe6f1f70..63feb136ea 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -367,13 +367,6 @@ export function createProjectionNode({ */ resumingFrom?: IProjectionNode - /** - * Whether this element is present in the DOM. Used to determine if an element - * is exiting (isPresent=false) and should have its animation controlled by - * the lead element that is resuming from it. - */ - isPresent?: boolean - /** * A reference to the element's latest animated values. This is a reference shared * between the element's VisualElement and the ProjectionNode. @@ -512,14 +505,6 @@ export function createProjectionNode({ return } - /** - * Check if this is a follow element in a crossfade (exiting element - * whose lead has resumeFrom pointing to it). These elements should - * NOT create their own animation - they get the lead's animation via - * resumingFrom.currentAnimation in startAnimation(). - */ - const isFollowInCrossfade = this.isPresent === false && !this.isLead() - // TODO: Check here if an animation exists const layoutTransition = this.options.transition || @@ -553,65 +538,57 @@ export function createProjectionNode({ const hasOnlyRelativeTargetChanged = !hasLayoutChanged && hasRelativeLayoutChanged - /** - * Skip animation handling for follow elements in a crossfade. - * These elements get the lead's animation via resumingFrom.currentAnimation - * in startAnimation(), so they shouldn't create their own animation. - * They also shouldn't call finishAnimation(), which would reset their state. - */ - if (!isFollowInCrossfade) { + if ( + this.options.layoutRoot || + this.resumeFrom || + hasOnlyRelativeTargetChanged || + (hasLayoutChanged && + (hasTargetChanged || !this.currentAnimation)) + ) { + if (this.resumeFrom) { + this.resumingFrom = this.resumeFrom + this.resumingFrom.resumingFrom = undefined + } + + const animationOptions = { + ...getValueTransition( + layoutTransition, + "layout" + ), + onPlay: onLayoutAnimationStart, + onComplete: onLayoutAnimationComplete, + } + if ( - this.options.layoutRoot || - this.resumeFrom || - hasOnlyRelativeTargetChanged || - (hasLayoutChanged && - (hasTargetChanged || !this.currentAnimation)) + visualElement.shouldReduceMotion || + this.options.layoutRoot ) { - if (this.resumeFrom) { - this.resumingFrom = this.resumeFrom - this.resumingFrom.resumingFrom = undefined - } - - const animationOptions = { - ...getValueTransition( - layoutTransition, - "layout" - ), - onPlay: onLayoutAnimationStart, - onComplete: onLayoutAnimationComplete, - } - - if ( - visualElement.shouldReduceMotion || - this.options.layoutRoot - ) { - animationOptions.delay = 0 - animationOptions.type = false - } - - this.startAnimation(animationOptions) - /** - * Set animation origin after starting animation to avoid layout jump - * caused by stopping previous layout animation - */ - this.setAnimationOrigin( - delta, - hasOnlyRelativeTargetChanged - ) - } else { - /** - * If the layout hasn't changed and we have an animation that hasn't started yet, - * finish it immediately. Otherwise it will be animating from a location - * that was probably never committed to screen and look like a jumpy box. - */ - - if (!hasLayoutChanged) { - finishAnimation(this) - } - - if (this.isLead() && this.options.onExitComplete) { - this.options.onExitComplete() - } + animationOptions.delay = 0 + animationOptions.type = false + } + + this.startAnimation(animationOptions) + /** + * Set animation origin after starting animation to avoid layout jump + * caused by stopping previous layout animation + */ + this.setAnimationOrigin( + delta, + hasOnlyRelativeTargetChanged + ) + } else { + /** + * If the layout hasn't changed and we have an animation that hasn't started yet, + * finish it immediately. Otherwise it will be animating from a location + * that was probably never committed to screen and look like a jumpy box. + */ + + if (!hasLayoutChanged) { + finishAnimation(this) + } + + if (this.isLead() && this.options.onExitComplete) { + this.options.onExitComplete() } } From 0c39787f7290f76172f604f8e462768bcf6e2048 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 15:12:37 +0100 Subject: [PATCH 22/25] Simplify LayoutAnimationBuilder - Remove detectMutations, use simple before/after element comparison - Remove unnecessary context checks with early return - Simplify shared element handling (remove isPresent assignments) - Clean up shared() method signature Co-Authored-By: Claude Opus 4.5 --- .../src/layout/LayoutAnimationBuilder.ts | 131 ++++++++---------- 1 file changed, 59 insertions(+), 72 deletions(-) diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index f04d8ca6b1..82886f9c95 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -57,96 +57,83 @@ export class LayoutAnimationBuilder implements PromiseLike { if (this.executed) return this.executed = true - const animations: AcceptedAnimations[] = [] let context: ProjectionContext | undefined - try { - // Phase 1: Pre-mutation - Build projection tree and take snapshots - const beforeElements = getLayoutElements(this.scope) + // 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() - ) + if (beforeElements.length > 0) { + context = buildProjectionTree( + beforeElements, + undefined, + this.getBuildOptions() + ) - context.root.startUpdate() + context.root.startUpdate() - for (const node of context.nodes.values()) { - node.isLayoutDirty = false - node.willUpdate() - } + 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) + // Phase 2: Execute DOM update + this.updateDom() - const entering = afterElements.filter((el) => !beforeSet.has(el)) - const exiting = beforeElements.filter((el) => !afterSet.has(el)) + // Phase 3: Post-mutation - Compare before/after elements + const afterElements = getLayoutElements(this.scope) + const beforeSet = new Set(beforeElements) + const afterSet = new Set(afterElements) - // Build projection nodes for entering elements - if (entering.length > 0) { - context = buildProjectionTree( - entering, - context, - this.getBuildOptions() - ) - } + const entering = afterElements.filter((el) => !beforeSet.has(el)) + const exiting = beforeElements.filter((el) => !afterSet.has(el)) - // Handle shared elements - if (context) { - // Remove exiting elements from stack (no exit animation supported) - for (const element of exiting) { - const node = context.nodes.get(element) - const stack = node?.getStack() - if (stack) { - stack.remove(node) - } - } - - // Promote entering elements to become lead - for (const element of entering) { - const node = context.nodes.get(element) - node?.promote() - } - } + // Build projection nodes for entering elements + if (entering.length > 0) { + context = buildProjectionTree( + entering, + context, + this.getBuildOptions() + ) + } - // Phase 4: Animate - if (context) { - context.root.didUpdate() + // No layout elements - return empty animation + if (!context) { + this.notifyReady(new GroupAnimation([])) + return + } - await new Promise((resolve) => - frame.postRender(() => resolve()) - ) + // Handle shared elements + for (const element of exiting) { + const node = context.nodes.get(element) + node?.getStack()?.remove(node) + } - for (const node of context.nodes.values()) { - if (node.currentAnimation) { - animations.push(node.currentAnimation) - } - } - } + for (const element of entering) { + context.nodes.get(element)?.promote() + } - const groupAnimation = new GroupAnimation(animations) + // Phase 4: Animate + context.root.didUpdate() - groupAnimation.finished.then(() => { - if (context) { - cleanupProjectionTree(context) - } - }) + await new Promise((resolve) => + frame.postRender(() => resolve()) + ) - this.notifyReady(groupAnimation) - } catch (error) { - if (context) { - cleanupProjectionTree(context) + const animations: AcceptedAnimations[] = [] + for (const node of context.nodes.values()) { + if (node.currentAnimation) { + animations.push(node.currentAnimation) } - throw error } + + const groupAnimation = new GroupAnimation(animations) + + groupAnimation.finished.then(() => { + cleanupProjectionTree(context!) + }) + + this.notifyReady(groupAnimation) } private getBuildOptions(): BuildProjectionTreeOptions { From 66cd2e6d5d386b63c6d49d6f44d0b42dac28b794 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 15:15:00 +0100 Subject: [PATCH 23/25] Latest --- .../interrupt-animation.spec.ts | 29 ---------------- tests/animate-layout/repeat-animation.spec.ts | 19 ----------- .../shared-element-a-ab-a.spec.ts | 33 ------------------- .../shared-element-crossfade.spec.ts | 18 ---------- .../shared-element-no-crossfade.spec.ts | 18 ---------- 5 files changed, 117 deletions(-) delete mode 100644 tests/animate-layout/interrupt-animation.spec.ts delete mode 100644 tests/animate-layout/repeat-animation.spec.ts delete mode 100644 tests/animate-layout/shared-element-a-ab-a.spec.ts delete mode 100644 tests/animate-layout/shared-element-crossfade.spec.ts delete mode 100644 tests/animate-layout/shared-element-no-crossfade.spec.ts diff --git a/tests/animate-layout/interrupt-animation.spec.ts b/tests/animate-layout/interrupt-animation.spec.ts deleted file mode 100644 index 208dff4941..0000000000 --- a/tests/animate-layout/interrupt-animation.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test, expect } from "@playwright/test" - -test.describe("animateLayout", () => { - test("interrupted animations should start from visual position, not layout position", async ({ - page, - }) => { - // Capture console logs - const logs: string[] = [] - page.on("console", (msg) => { - logs.push(msg.text()) - }) - - await page.goto( - "http://localhost:8000/animate-layout/interrupt-animation.html" - ) - - // Wait for the test to complete (animation is 0.5s + 0.25s wait + some extra) - await page.waitForTimeout(1500) - - // Print logs for debugging - console.log("Console logs:", logs) - - // Check that no elements have data-layout-correct="false" - const incorrectElements = await page.locator( - '[data-layout-correct="false"]' - ) - await expect(incorrectElements).toHaveCount(0) - }) -}) diff --git a/tests/animate-layout/repeat-animation.spec.ts b/tests/animate-layout/repeat-animation.spec.ts deleted file mode 100644 index bf76a04231..0000000000 --- a/tests/animate-layout/repeat-animation.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test, expect } from "@playwright/test" - -test.describe("animateLayout", () => { - test("repeat-animation: subsequent animations should animate, not happen instantly", async ({ - page, - }) => { - // Change base URL for this test - await page.goto("http://localhost:8000/animate-layout/repeat-animation.html") - - // Wait for the test to complete - await page.waitForTimeout(500) - - // Check that no elements have data-layout-correct="false" - const incorrectElements = await page.locator( - '[data-layout-correct="false"]' - ) - await expect(incorrectElements).toHaveCount(0) - }) -}) diff --git a/tests/animate-layout/shared-element-a-ab-a.spec.ts b/tests/animate-layout/shared-element-a-ab-a.spec.ts deleted file mode 100644 index 2c4a112ac8..0000000000 --- a/tests/animate-layout/shared-element-a-ab-a.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from "@playwright/test" - -test.describe("animateLayout shared elements A-AB-A", () => { - test("crossfade works for A -> AB -> A pattern", async ({ page }) => { - // Capture console errors - const errors: string[] = [] - page.on('console', msg => { - errors.push(`[${msg.type()}] ${msg.text()}`) - }) - - await page.goto( - "http://localhost:8000/animate-layout/shared-element-a-ab-a.html" - ) - - // Wait for the test to complete - await page.waitForTimeout(2000) - - // Log all console messages for debugging - console.log('All console:', errors) - - // Also get error messages from the page - const pageErrors = await page.locator('p').allTextContents() - if (pageErrors.length > 0) { - console.log('Page errors:', pageErrors) - } - - // Check that no elements have data-layout-correct="false" - const incorrectElements = await page.locator( - '[data-layout-correct="false"]' - ) - await expect(incorrectElements).toHaveCount(0) - }) -}) diff --git a/tests/animate-layout/shared-element-crossfade.spec.ts b/tests/animate-layout/shared-element-crossfade.spec.ts deleted file mode 100644 index a25fcb66e0..0000000000 --- a/tests/animate-layout/shared-element-crossfade.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from "@playwright/test" - -test.describe("animateLayout shared elements", () => { - test("crossfade works when both elements are in DOM", async ({ page }) => { - await page.goto( - "http://localhost:8000/animate-layout/shared-element-crossfade.html" - ) - - // Wait for the test to complete - await page.waitForTimeout(500) - - // Check that no elements have data-layout-correct="false" - const incorrectElements = await page.locator( - '[data-layout-correct="false"]' - ) - await expect(incorrectElements).toHaveCount(0) - }) -}) diff --git a/tests/animate-layout/shared-element-no-crossfade.spec.ts b/tests/animate-layout/shared-element-no-crossfade.spec.ts deleted file mode 100644 index 0b926fc8b5..0000000000 --- a/tests/animate-layout/shared-element-no-crossfade.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from "@playwright/test" - -test.describe("animateLayout shared elements", () => { - test("shared elements should not fade in by default", async ({ page }) => { - await page.goto( - "http://localhost:8000/animate-layout/shared-element-no-crossfade.html" - ) - - // Wait for the test to complete - await page.waitForTimeout(500) - - // Check that no elements have data-layout-correct="false" - const incorrectElements = await page.locator( - '[data-layout-correct="false"]' - ) - await expect(incorrectElements).toHaveCount(0) - }) -}) From e54c92e7da38450ec6b86945bf80950e2209a1e3 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 15:50:56 +0100 Subject: [PATCH 24/25] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From eab3b0eba93607d900fd05cab5feb0b4f561c3bd Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Sun, 18 Jan 2026 15:51:57 +0100 Subject: [PATCH 25/25] v12.27.0 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 43cd0cd80c..4881198014 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.27.0-alpha.6", + "version": "12.27.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.6", - "motion": "^12.27.0-alpha.6", - "motion-dom": "^12.27.0-alpha.6", + "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/next/package.json b/dev/next/package.json index 9546b60804..a378422ace 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.27.0-alpha.6", + "version": "12.27.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.27.0-alpha.6", + "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 8107ac81b1..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.27.0-alpha.6", + "version": "12.27.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.27.0-alpha.6", + "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 222dc5253e..4eef579397 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.27.0-alpha.6", + "version": "12.27.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.27.0-alpha.6", + "framer-motion": "^12.27.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 82cd957df1..3ceb370381 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.27.0-alpha.6", + "version": "12.27.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 265811d0a2..675918e9f3 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.27.0-alpha.6", + "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.27.0-alpha.6", + "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 27de6b62c7..4824b33042 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.27.0-alpha.6", + "version": "12.27.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index db0aef925d..de10e7e543 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.27.0-alpha.6", + "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.27.0-alpha.6", + "framer-motion": "^12.27.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index d96a58dc92..fc24c247e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.27.0-alpha.6, 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.27.0-alpha.6 + 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.27.0-alpha.6 - motion: ^12.27.0-alpha.6 - motion-dom: ^12.27.0-alpha.6 + 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.27.0-alpha.6, 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.27.0-alpha.6, 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.27.0-alpha.6 + 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.27.0-alpha.6 + 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.27.0-alpha.6 + 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.27.0-alpha.6 + framer-motion: ^12.27.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0