plans/issues/issue-2611.md
rotateZ work during/after shared layout animationsExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report — do not improvise. When done, update the row for this issue in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2611 --jq .state→ must beopen.git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/projection/styles/transform.ts packages/motion-dom/src/projection/utils/has-transform.ts packages/motion-dom/src/projection/animation/mix-values.tsIf any of these changed, re-read them and compare against the "Current state" excerpts; on a mismatch treat it as a STOP condition.
42bfbe3ed, 2026-06-11Reporter: an element with style={{ rotateZ: 45 }} and a layoutId loses its
rotation when a shared layout animation runs; the same code with rotate
instead of rotateZ works (confirmed by the reporter in the issue comments).
rotate and rotateZ are aliases in CSS, and the normal (non-projection)
render path supports both — commit 768877517 (2022) added rotateZ to
packages/framer-motion/src/render/html/utils/transform.ts. The projection
engine was never given the same treatment, so any projection-driven render
silently drops rotateZ.
Three projection-side sites handle rotate/rotateX/rotateY but omit
rotateZ:
packages/motion-dom/src/projection/styles/transform.ts:32-51 —
buildProjectionTransform() builds the transform string applied while a
projection node is active. It destructures and emits:
const { transformPerspective, rotate, pathRotation, rotateX, rotateY, skewX, skewY } = latestTransform
...
if (rotate) transform += `rotate(${rotate}deg) `
// Additive `rotate()` so user `rotate` isn't clobbered.
if (pathRotation) transform += `rotate(${pathRotation}deg) `
if (rotateX) transform += `rotateX(${rotateX}deg) `
if (rotateY) transform += `rotateY(${rotateY}deg) `
rotateZ is never read → dropped from the projected transform. This is
the primary bug.
packages/motion-dom/src/projection/utils/has-transform.ts:16-27 —
hasTransform() checks values.rotate || values.rotateX || values.rotateY
(plus skews/scale/translate) but not values.rotateZ. A node whose only
transform is rotateZ is treated as untransformed in box measurement
correction (applyTransform, removeTransform).
packages/motion-dom/src/projection/animation/mix-values.ts:92-96 —
mixValues() crossfades follow.rotate/lead.rotate between shared
stack members but not rotateZ.
Note the projection engine already knows about rotateZ elsewhere:
resetSkewAndRotation() in
packages/motion-dom/src/projection/node/create-projection-node.ts:1934-1944
checks latestValues.rotateZ, and transformAxes = ["", "X", "Y", "Z"]
(line 84) means rotateZ IS reset to 0 before measurement — and then never
re-applied by buildProjectionTransform. That is exactly the reported symptom
("rotateZ no longer works after the animation").
Reproduction sandbox
(https://codesandbox.io/p/sandbox/framer-motion-shared-layout-animation-with-rotatez-forked-j8ptt5)
was unreachable at planning time (CodeSandbox API blocked, HTTP 403). The
issue body + comments are sufficient: shared layout animation between two
elements where one has rotateZ in style.
| Purpose | Command | Expected |
|---|---|---|
| Install (only if needed) | yarn (repo root, foreground) | exit 0 |
| Build all | yarn build (repo root) | exit 0 |
| Unit tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="transform" | all pass |
| Collect HTML fixtures | node dev/inc/collect-html-tests.js | regenerates packages/framer-motion/cypress/fixtures/projection-tests.json |
| HTML fixture server | cd dev/html && yarn vite --port 8000 (port 8000 is hardcoded in the spec's Cypress.config) | serving |
| HTML projection suite | cd packages/framer-motion && npx cypress run --config-file=cypress.html.json --spec cypress/integration-html/projection.ts | all fixtures green |
In scope (the only files you should modify):
packages/motion-dom/src/projection/styles/transform.tspackages/motion-dom/src/projection/styles/__tests__/transform.test.tspackages/motion-dom/src/projection/utils/has-transform.tspackages/motion-dom/src/projection/animation/mix-values.tsdev/html/public/projection/shared-promote-new-mix-rotate-z.html (create)Out of scope:
packages/framer-motion/src/render/html/utils/transform.ts — the plain
render path already supports rotateZ.resetSkewAndRotation / create-projection-node.ts — already handles
rotateZ; do not edit (in-flight PRs #3748/#3749 touch this file).fix/2611-rotatez-projectionSupport rotateZ in projection transforms (#2611)In packages/motion-dom/src/projection/styles/__tests__/transform.test.ts,
add a test modeled on the existing rotate cases (see lines ~35-103 of that
file), e.g.:
expect(
buildProjectionTransform(delta, { x: 1, y: 1 }, { rotateZ: 10 })
).toEqual("translate3d(100px, 100px, 0px) rotateZ(10deg) scale(2, 4)")
(Reuse the same delta the neighbouring rotate test uses, and mirror its
exact expected translate/scale output, swapping in rotateZ(10deg) after the
treeScale segment in the same position rotate(...) occupies.)
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="transform" → the new test FAILS (output lacks rotateZ). Existing tests pass.
buildProjectionTransformIn packages/motion-dom/src/projection/styles/transform.ts, add rotateZ to
the destructure and emit it alongside the other rotates:
if (rotateZ) transform += `rotateZ(${rotateZ}deg) `
Place it after the rotateY line so ordering matches the non-projection
builder.
Verify: same jest command → all pass.
hasTransform and mixValueshas-transform.ts: add values.rotateZ || next to values.rotateY.mix-values.ts:92-96: duplicate the rotate mixing block for rotateZ:
if (follow.rotateZ || lead.rotateZ) {
target.rotateZ = mixNumber(
(follow.rotateZ as number) || 0,
(lead.rotateZ as number) || 0,
progress
)
}
rotate block at line 92.)Verify: npx jest --config packages/motion-dom/jest.config.json → no regressions.
Create dev/html/public/projection/shared-promote-new-mix-rotate-z.html as a
copy of dev/html/public/projection/shared-promote-new-mix-rotate.html,
replacing newBoxProjection.setValue("rotate", 40) with
setValue("rotateZ", 40) and the corresponding assertion (that fixture uses
matchRotate from window.Assert — read
dev/html/src/imports/script-assert.js first to confirm how matchRotate
reads rotation; if it only inspects the rotate latest value rather than
computed style, assert via computed style transform containing a rotation
matrix instead, still flipping data-layout-correct on failure like the other
fixtures do).
Then: node dev/inc/collect-html-tests.js, yarn build, start the dev/html
Vite server on port 8000, and run the HTML projection suite (commands table).
Verify: suite passes including the new fixture. As a sanity check, revert Step 2 temporarily and confirm the new fixture fails, then re-apply.
buildProjectionTransform emits rotateZ(…deg) (Step 1).{ rotateZ: 0 } emits no rotateZ segment.yarn build exits 0shared-promote-new-mix-rotate-z.htmlgit status shows only in-scope files modifiedplans/issues/README.md row updatedbuildProjectionTransform signature or file location differs from the
excerpt (PR #3748/#3749 may have landed and reshaped projection styles).create-projection-node.ts; report findings rather than editing that
file.render/html/utils/transform.ts's
transformPropOrder semantics.