Skip to content

fix transform motion svg-attribute conflicts with style-transform#3628

Closed
malo0n wants to merge 2 commits intomotiondivision:mainfrom
malo0n:worktree-fix-issue-3082
Closed

fix transform motion svg-attribute conflicts with style-transform#3628
malo0n wants to merge 2 commits intomotiondivision:mainfrom
malo0n:worktree-fix-issue-3082

Conversation

@malo0n
Copy link

@malo0n malo0n commented Mar 11, 2026

This pull request addresses a bug where SVG attributes like transform could incorrectly render as [object Object] when using MotionValues, and ensures that SVG-animated properties are handled correctly in the DOM. It introduces detection for SVGAnimated* properties, updates attribute handling logic, and adds tests to prevent regressions. The changes improve compatibility with SVG and prevent incorrect rendering of MotionValue objects.

fixes #3082

SVG attribute handling improvements:

  • Added a utility function isSVGAnimatedProperty to detect SVGAnimated* properties (e.g., transform), ensuring they are set via setAttribute instead of as properties, which prevents rendering issues.
  • Updated addSVGValue and canSetAsProperty logic to use isSVGAnimatedProperty, ensuring SVG-animated properties are handled as attributes, not as DOM properties. [1] [2] [3]
  • Improved the handling of transform in buildSVGAttrs to ensure user-provided SVG transform attributes override motion-synthesized transforms and prevent leaking MotionValue objects into the DOM.

Testing and reliability:

  • Added tests to verify that MotionValues used as SVG attributes render correctly and do not appear as [object Object], and that animated MotionValues update SVG attributes as expected.

Minor fixes:

  • Improved regex in convertAttrKey for better Unicode handling.

It's my first contribution in open-source project ever, so please let me know if smth wrong! I've tried to follow all recommendations

@greptile-apps
Copy link

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR fixes a real bug (issue #3082) where SVG transform MotionValues rendered as [object Object] in the DOM. The core fix in buildSVGAttrs — guarding the CSS-transform promotion path with latest.transform === undefined — is correct and well-targeted. The two new tests confirm the [object Object] regression is resolved.

However, there are two meaningful concerns introduced by the supporting changes:

  • isSVGAnimatedProperty is over-broad: The duck-type check ("baseVal" in value) matches all SVGAnimated* interfaces, including SVGAnimatedLength on cx, cy, r, width, height, x, y, etc. This causes addSVGValue to route those geometry properties to setAttribute instead of addStyleValue for MotionValues bound via the effects pipeline — a silent behavioral change that has no test coverage.
  • animate={{ transform: "..." }} behavior change: The guard latest.transform === undefined in buildSVGAttrs also catches the animate prop (not just MotionValues), so SVG child elements using animate={{ transform: "..." }} will now keep transform as an SVG attribute rather than promoting it to a CSS style.transform. This changes the compositing and transform-box: fill-box behavior for those elements.

Minor: the import added at the top of effects/svg/index.ts disrupts the existing import ordering convention.

Confidence Score: 2/5

  • The targeted fix for [object Object] is correct, but two unintended behavioral side-effects need resolution before merging.
  • The core bug fix in build-attrs.ts is solid, but the isSVGAnimatedProperty utility is broader than intended and silently changes routing for SVGAnimated geometry properties (width, height, cx, cy, etc.) in the effects pipeline. Additionally, the latest.transform === undefined guard also affects animate={{ transform }} on SVG child elements, potentially breaking CSS-transform–based compositing for those cases. Both issues lack test coverage.
  • Pay close attention to packages/motion-dom/src/effects/utils/is-svg-animated-property.ts (overly broad SVGAnimated* detection) and packages/motion-dom/src/render/svg/utils/build-attrs.ts (unintended behavior change for animate={{ transform }}).

Important Files Changed

Filename Overview
packages/motion-dom/src/effects/utils/is-svg-animated-property.ts New utility that duck-types SVGAnimated* properties via "baseVal" in value. While the heuristic is correct for its stated purpose, it inadvertently matches all SVGAnimated* geometry properties (cx, cy, r, width, height, x, y, etc.) not just transform, creating unintended routing side-effects in addSVGValue.
packages/motion-dom/src/render/svg/utils/build-attrs.ts Core fix for the [object Object] bug. The latest.transform === undefined guard correctly separates motion-synthesized transforms from user-supplied transform values, but silently changes behavior for animate={{ transform: "..." }} on SVG child elements by keeping them as SVG attributes instead of CSS styles.
packages/motion-dom/src/effects/attr/index.ts Correct guard added to canSetAsProperty: SVGAnimated* IDL properties that expose a setter but cannot be set directly with primitive values are now routed to setAttribute. Logic is sound.
packages/motion-dom/src/effects/svg/index.ts Adds isSVGAnimatedProperty check before the element.style routing in addSVGValue, correctly catching transform (and other SVGAnimated* properties). Minor import-order nit. The broad scope of the check may silently affect width/height/cx/cy animation routing.
packages/framer-motion/src/motion/tests/component-svg.test.tsx Two new regression tests cover the [object Object] rendering bug and MotionValue animation via SVG transform attribute. Tests are well-structured. Missing coverage for animate={{ transform: "..." }} with CSS-transform semantics and for SVGAnimated geometry properties (cx, cy, width, etc.).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["SVG MotionValue prop\ne.g. transform=mv"] --> B{Pipeline}
    B --> C["Effects pipeline\nsvgEffect / addSVGValue"]
    B --> D["Render pipeline\nbuildSVGAttrs"]

    C --> E{"isSVGAnimatedProperty\nelement has baseVal?"}
    E -- yes --> F["addAttrValue\nsetAttribute"]
    E -- no --> G{"key in element.style?"}
    G -- yes --> H["addStyleValue\nelement.style key = v"]
    G -- no --> F

    D --> I["buildHTMLStyles\nsets state.style.transform"]
    I --> J["state.attrs = state.style\nstate.style = {}"]
    J --> K{"attrs.transform\ntruthy?"}
    K -- no --> N["No transform set"]
    K -- yes --> L{"latest.transform\n=== undefined?"}
    L -- "yes: motion-synthesized\nx/y/scale/rotate" --> M["style.transform = attrs.transform\nCSS transform applied"]
    L -- "no: user-supplied\ntransform value" --> O["attrs.transform kept\nSVG attribute applied"]
Loading

Last reviewed commit: 7ef68ce

Comment on lines +1 to +9
export function isSVGAnimatedProperty(element: Element, name: string): boolean {
const value = (element as any)[name]

return (
value !== null &&
typeof value === "object" &&
"baseVal" in value
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSVGAnimatedProperty is too broad — affects width, height, cx, cy, r, etc.

The check "baseVal" in value is true for any SVGAnimated* interface, not just SVGAnimatedTransformList. This means width, height, cx, cy, r, rx, ry, x, y (all of which return SVGAnimatedLength), viewBox (SVGAnimatedRect), etc., will all satisfy this predicate.

The effect is that in addSVGValue (in effects/svg/index.ts), the new early-return for isSVGAnimatedProperty now intercepts all of these geometry properties and routes them to addAttrValue (setAttribute), bypassing the existing key in element.style check that would previously route them through addStyleValue. For example, animating width on a <rect> as a MotionValue would now be set as an SVG attribute instead of a CSS property, which is a silent behavior change from the original code.

If the intent is only to fix transform, consider narrowing the check. For example:

export function isSVGTransformProperty(element: Element, name: string): boolean {
    if (name !== "transform") return false
    const value = (element as any)[name]
    return (
        value !== null &&
        typeof value === "object" &&
        "baseVal" in value
    )
}

Or, accept that all SVGAnimated* geometry attributes should go through setAttribute (and verify this is intentional).

Comment on lines 59 to 64
if (attrs.transform) {
style.transform = attrs.transform
delete attrs.transform
if (latest.transform === undefined) {
style.transform = attrs.transform
delete attrs.transform
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latest.transform === undefined breaks CSS-transform path when using animate={{ transform: ... }}

The outer condition if (attrs.transform) will be entered any time attrs.transform is set (i.e., whenever a transform value exists in the render state). The inner guard latest.transform === undefined is meant to distinguish between a synthesized transform (from x/y/scale/rotate props, where latest.transform is undefined) and an explicit user-supplied transform value.

However, consider the scenario where someone uses animate={{ transform: "translate(50px, 0)" }}. In this case:

  • latestValues.transform = "translate(50px, 0)" (truthy), so buildHTMLStyles skips synthesizing a transform, and instead sets style.transform = "translate(50px, 0)"
  • After state.attrs = state.style, attrs.transform = "translate(50px, 0)"
  • latest.transform is "translate(50px, 0)" (not undefined)
  • The new code skips the style.transform = attrs.transform assignment, leaving attrs.transform as an SVG attribute

For SVG child elements (non-<svg> tags), a CSS transform applied via the animate prop would previously have been moved to style.transform (enabling CSS transitions and transform-box: fill-box). With this change, it instead stays as an SVG attribute. This may or may not be desirable, but it is an undocumented behavioral change for animate={{ transform: ... }} on SVG elements.

It would be good to add a test covering animate={{ transform: "..." }} to confirm the intended behavior.

@mattgperry
Copy link
Collaborator

Thanks for the PR! Though handled this through #3629

@mattgperry mattgperry closed this Mar 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] MotionValues cannot be used in motion.g element's transform attribute

2 participants