plans/issues/issue-2609.md
scaleZ render in the transform string like every other transform propExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2609 --jq .state→ expectedopen. If closed, STOP.git log --oneline 42bfbe3ed..HEAD -- packages/motion-dom/src/render/utils/keys-transform.ts packages/motion-dom/src/render/html/utils/build-transform.ts packages/motion-dom/src/effects/style/transform.ts packages/motion-dom/src/render/dom/parse-transform.tsIf any commits appear, compare the "Current state" excerpts against the live code before proceeding; on a mismatch, treat it as a STOP condition. In particular, if PR #3749 (worktree-style-effect) has merged, the twobuildTransformimplementations have been reorganised — the core fix (Step 2) is unchanged, but re-locate the test files per the notes in "Interaction with PR #3749" below.
42bfbe3ed, 2026-06-11motion.div initial={{ scaleZ: 2 }} (or via animate/variants) silently
drops scaleZ: every other transform (scaleX, scaleY, rotateZ,
translateZ) appears in the built transform string, but scaleZ never
does. The TypeScript types already advertise support
(CSSStyleDeclarationWithTransform.scaleZ: number at
packages/motion-dom/src/animation/types.ts:608 and
TransformProperties.scaleZ at types.ts:714), and the value-type map
already defines it — the only gap is that scaleZ is missing from
transformPropOrder, the array both transform builders iterate. This is a
confirmed, reproducible bug with a one-line root cause.
packages/motion-dom/src/render/utils/keys-transform.ts:4-22 —
transformPropOrder lists every transform key in serialization order.
scaleZ is absent (excerpt; note scaleX, scaleY then straight to
rotate):
export const transformPropOrder = [
"transformPerspective",
"x",
"y",
"z",
"translateX",
"translateY",
"translateZ",
"scale",
"scaleX",
"scaleY",
"rotate",
...
transformProps (line 34) is derived from this array, so scaleZ is also
not recognised as a transform prop by buildHTMLStyles
(packages/motion-dom/src/render/html/utils/build-styles.ts:30) — it falls
through to the plain-style branch and is written as an invalid scaleZ
style, i.e. dropped by the browser.transformPropOrder and already handle a
scaleZ entry generically via key.startsWith("scale") (default value 1):
packages/motion-dom/src/render/html/utils/build-transform.ts:36-62
(VisualElement/React pipeline)packages/motion-dom/src/effects/style/transform.ts:19-38
(effects/vanilla pipeline)packages/motion-dom/src/value/types/maps/transform.ts:19
→ scaleZ: scale. No change needed there.packages/motion-dom/src/render/dom/parse-transform.ts:17-60 — matrix
parsers used by readTransformValue when reading a transform prop's origin
off a computed matrix()/matrix3d(). Maps are keyed per prop;
scaleZ is absent from both matrix2dParsers and matrix3dParsers.
Without an entry, parsers[name] is undefined and
values[undefined as any] yields undefined (silent NaN downstream).packages/motion-dom/src/animation/keyframes/utils/unit-conversion.ts:19-38
— removeNonTranslationalTransform handles new scale keys generically
(key.startsWith("scale") ? 1 : 0). No change needed.packages/motion-dom/src/render/utils/keys-position.ts — spreads
transformPropOrder into positionalKeys. Adding scaleZ is consistent
with how rotate/scale are already handled there
(DOMKeyframesResolver.readKeyframes guards every positionalValues[name]
access — see packages/motion-dom/src/animation/keyframes/DOMKeyframesResolver.ts:97,118).packages/motion-dom/src/render/svg/utils/scrape-motion-values.ts:24
maps SVG props found in transformPropOrder to attr* names. After this
change, a scaleZ MotionValue passed as a direct prop (not style) to an
SVG motion component would map to attrScaleZ. scaleZ is meaningless on
SVG attributes, so this is acceptable; do not special-case it.#3749 rewrites both build-transform.ts and effects/style/transform.ts
and deletes packages/framer-motion/src/render/html/utils/__tests__/build-styles.test.ts,
but does NOT touch keys-transform.ts or parse-transform.ts. Because both
present and future builders iterate transformPropOrder, Step 2's one-line
fix is valid before and after #3749. If #3749 has already merged when you
execute this plan, put the buildTransform unit test wherever the surviving
buildTransform tests live (search: grep -rln "buildTransform" packages/*/src --include="*.test.*").
| Purpose | Command (repo root) | Expected on success |
|---|---|---|
| Build all packages | yarn build | exit 0 |
| Unit tests (targeted) | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="build-transform" | all pass |
| motion-dom tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="transform" | all pass |
| Full framer-motion Jest | cd packages/framer-motion && yarn test-client | pass (ignore known SSR TextEncoder / use-velocity failures) |
| Lint | yarn lint | exit 0 |
In scope (the only files you should modify):
packages/motion-dom/src/render/utils/keys-transform.tspackages/motion-dom/src/render/dom/parse-transform.tspackages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts (extend)packages/framer-motion/src/motion/__tests__/ — one component-level test (extend an existing render test file, e.g. where initial transform rendering is already asserted)Out of scope:
packages/motion-dom/src/effects/style/transform.ts and
packages/motion-dom/src/render/html/utils/build-transform.ts — they need
no edits (they iterate the array) and #3749 rewrites both; touching them
invites conflicts.scaleZ is already typed.scaleZ as an individual value — transforms are
serialized into one transform string; nothing extra needed.fix/issue-2609-scalez from main.git log --oneline), e.g.
Fix scaleZ not being applied to transform string (#2609).packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts
(imports buildTransform from motion-dom), add:
it("Outputs scaleZ", () => {
expect(buildTransform({ scaleZ: 2 }, {})).toBe("scaleZ(2)")
expect(buildTransform({ scaleZ: 1 }, {})).toBe("none")
expect(
buildTransform({ scaleX: 2, scaleZ: 3, rotateZ: 90 }, {})
).toBe("scaleX(2) scaleZ(3) rotateZ(90deg)")
})
<motion.div initial={{ scaleZ: 2, rotateX: 10 }} /> and
assert container.firstChild has
style.transform === "scaleZ(2) rotateX(10deg)". Model after existing
initial-render transform assertions in
packages/framer-motion/src/motion/__tests__/ (search
grep -rn "toHaveStyle" packages/framer-motion/src/motion/__tests__/render.test.tsx | head).Verify: npx jest --config packages/framer-motion/jest.config.json --testPathPattern="build-transform"
→ the new assertions FAIL with output "none" / missing scaleZ(...) (the
bug, reproduced). If they pass, STOP — the bug no longer exists; reclassify.
scaleZ to transformPropOrderIn packages/motion-dom/src/render/utils/keys-transform.ts, insert
"scaleZ" after "scaleY":
"scale",
"scaleX",
"scaleY",
"scaleZ",
"rotate",
Verify: yarn build → exit 0, then re-run the Step 1 Jest command → new
tests pass.
scaleZ matrix parsersIn packages/motion-dom/src/render/dom/parse-transform.ts:
matrix2dParsers (line 17): add scaleZ: () => 1, (a 2D matrix implies no
Z scale; note parser values that are numbers are treated as array indices,
so it MUST be a function, not the literal 1).matrix3dParsers (line 43): add
scaleZ: (v) => Math.sqrt(v[8] * v[8] + v[9] * v[9] + v[10] * v[10]),
(length of the third column basis vector, mirroring how scaleX/scaleY
use columns 1 and 2).Add unit coverage where parseValueFromTransform is already tested
(grep -rln "parseValueFromTransform" packages --include="*.test.*"; if no
test file exists, add assertions to the Step 1 build-transform test file):
parseValueFromTransform("none", "scaleZ") → 1parseValueFromTransform("matrix3d(1,0,0,0, 0,1,0,0, 0,0,3,0, 0,0,0,1)", "scaleZ") → 3parseValueFromTransform("matrix(1, 0, 0, 1, 10, 20)", "scaleZ") → 1Verify: targeted Jest run for the touched test files → all pass.
Verify:
cd packages/framer-motion && yarn test-client → no new failures
(pre-existing SSR TextEncoder and use-velocity failures are known — ignore).npx jest --config packages/motion-dom/jest.config.json → pass.yarn lint → exit 0.build-transform.test.ts: scaleZ serialization, default-1 collapse to
"none", ordering between scaleX and rotateZ (Step 1).initial={{ scaleZ: 2, rotateX: 10 }} produces the
combined transform string (Step 1).parse-transform: scaleZ from none, matrix3d, and 2D matrix (Step 3).packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts.yarn build exits 0yarn test-client shows no new failuresgrep -n '"scaleZ"' packages/motion-dom/src/render/utils/keys-transform.ts returns one hit between scaleY and rotategit status shows no files outside the in-scope list modifiedplans/issues/README.md status row updatedkeys-transform.ts no longer contains the array as excerpted (drift —
likely #3749 follow-ups; re-ground before editing).transformPropOrder consumer makes an
assumption this plan missed. Report the failing test name and output.effects/style/transform.ts or
build-transform.ts (it shouldn't).transformPropOrder is consumed by build-transform (both pipelines),
parse-transform, unit-conversion, keys-position, and the SVG motion-value
scraper. Any future transform key addition must audit the same five sites
— consider extracting this checklist into a comment in keys-transform.ts.plans/issues/README.md row
for this plan is marked APPROVED.