Skip to content

Commit 86907d1

Browse files
mattgperryclaude
andcommitted
Fix SVG transform animations not applied without other SVG attributes (#3081)
The isHTMLElement() check used "offsetHeight" in element, which returns true for SVG elements in Chrome (where offsetHeight exists on SVGElement prototype, though deprecated). This caused supportsBrowserAnimation() to incorrectly route SVG transform animations through WAAPI instead of the JS animation path, bypassing the SVG render pipeline that sets transformBox and transformOrigin. Add an exclusion for SVG elements via "ownerSVGElement" in element. Fixes #3081 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 232a637 commit 86907d1

File tree

4 files changed

+145
-1
lines changed

4 files changed

+145
-1
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useState } from "react"
2+
import { motion } from "framer-motion"
3+
4+
/**
5+
* Test for issue #3081: SVG transform animations should work
6+
* even when other SVG attributes are not also animated.
7+
*/
8+
export function App() {
9+
const [animate, setAnimate] = useState(false)
10+
11+
return (
12+
<>
13+
<motion.svg
14+
id="svg-root"
15+
width={200}
16+
height={200}
17+
initial={
18+
animate
19+
? undefined
20+
: { rotate: 10 }
21+
}
22+
animate={
23+
animate
24+
? { rotate: 0 }
25+
: undefined
26+
}
27+
transition={{ duration: 0.1, ease: "linear" }}
28+
>
29+
<motion.g
30+
id="svg-g"
31+
transition={{ duration: 0.1, ease: "linear" }}
32+
initial={
33+
animate
34+
? undefined
35+
: {
36+
transform: "matrix(1,0,0,1, 50, 50)",
37+
stroke: "#ff0000",
38+
}
39+
}
40+
animate={
41+
animate
42+
? {
43+
transform: "matrix(1, 0, 0, 1, 0, 0)",
44+
stroke: "#00ffff",
45+
}
46+
: undefined
47+
}
48+
>
49+
<motion.rect
50+
id="svg-rect"
51+
x={0}
52+
y={0}
53+
width={30}
54+
height={30}
55+
strokeWidth="5px"
56+
initial={
57+
animate
58+
? undefined
59+
: {
60+
transform: "matrix(2,0,0,2, 0, 0)",
61+
fill: "#ffff00",
62+
}
63+
}
64+
animate={
65+
animate
66+
? {
67+
transform: "matrix(1, 0, 0, 1, 0, 0)",
68+
fill: "#ff00ff",
69+
}
70+
: undefined
71+
}
72+
transition={{ duration: 0.1, ease: "linear" }}
73+
/>
74+
</motion.g>
75+
</motion.svg>
76+
<button id="animate" onClick={() => setAnimate(true)}>
77+
Animate
78+
</button>
79+
</>
80+
)
81+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
describe("SVG transform animation (#3081)", () => {
2+
it("Applies transform animation without other SVG attributes animated", () => {
3+
cy.visit("?test=svg-transform-animation")
4+
.wait(50)
5+
.get("#svg-rect")
6+
.should(($rect: any) => {
7+
const rect = $rect[0] as SVGRectElement
8+
// Before animation, transform should be the initial matrix(2,0,0,2,0,0)
9+
expect(rect.style.transform).to.contain("matrix")
10+
})
11+
.get("#animate")
12+
.click()
13+
.wait(300)
14+
.get("#svg-rect")
15+
.should(($rect: any) => {
16+
const rect = $rect[0] as SVGRectElement
17+
const transform = rect.style.transform
18+
// After animation, transform should be the final identity matrix
19+
expect(transform).to.not.equal("")
20+
expect(transform).to.not.equal("none")
21+
expect(transform).to.contain("matrix")
22+
const match = transform.match(/matrix\(([^)]+)\)/)
23+
expect(match).to.not.be.null
24+
const values = match![1].split(",").map(Number)
25+
// Identity matrix: 1, 0, 0, 1, 0, 0
26+
expect(values[0]).to.be.closeTo(1, 0.1)
27+
expect(values[3]).to.be.closeTo(1, 0.1)
28+
})
29+
.get("#svg-g")
30+
.should(($g: any) => {
31+
const g = $g[0] as SVGGElement
32+
const transform = g.style.transform
33+
expect(transform).to.not.equal("")
34+
expect(transform).to.not.equal("none")
35+
expect(transform).to.contain("matrix")
36+
})
37+
.get("#svg-root")
38+
.should(($svg: any) => {
39+
const svg = $svg[0] as SVGSVGElement
40+
const transform = svg.style.transform
41+
// motion.svg treats transforms like HTML (rotate builds to "none" at 0deg)
42+
expect(transform).to.not.equal("")
43+
})
44+
})
45+
})

packages/motion-dom/src/utils/__tests__/is-html-element.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ describe("isHTMLElement", () => {
1919
expect(isHTMLElement(element)).toBe(false)
2020
})
2121

22+
it("should return false for inner SVG elements", () => {
23+
const rect = document.createElementNS(
24+
"http://www.w3.org/2000/svg",
25+
"rect"
26+
)
27+
expect(isHTMLElement(rect)).toBe(false)
28+
29+
const g = document.createElementNS(
30+
"http://www.w3.org/2000/svg",
31+
"g"
32+
)
33+
expect(isHTMLElement(g)).toBe(false)
34+
})
35+
2236
it("should return false for a null element", () => {
2337
const element = null
2438
expect(isHTMLElement(element)).toBe(false)

packages/motion-dom/src/utils/is-html-element.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@ import { isObject } from "motion-utils"
55
* that works across iframes
66
*/
77
export function isHTMLElement(element: unknown): element is HTMLElement {
8-
return isObject(element) && "offsetHeight" in element
8+
return (
9+
isObject(element) &&
10+
"offsetHeight" in element &&
11+
!("ownerSVGElement" in element)
12+
)
913
}

0 commit comments

Comments
 (0)