diff --git a/dev/react/src/tests/waapi-svg-zero-duration.tsx b/dev/react/src/tests/waapi-svg-zero-duration.tsx
new file mode 100644
index 0000000000..b09ba56019
--- /dev/null
+++ b/dev/react/src/tests/waapi-svg-zero-duration.tsx
@@ -0,0 +1,50 @@
+import { motion } from "framer-motion"
+import { useState } from "react"
+
+export const App = () => {
+ const [hidden, setHidden] = useState(false)
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/packages/framer-motion/cypress/integration/waapi-svg-zero-duration.ts b/packages/framer-motion/cypress/integration/waapi-svg-zero-duration.ts
new file mode 100644
index 0000000000..c301d3c7ce
--- /dev/null
+++ b/packages/framer-motion/cypress/integration/waapi-svg-zero-duration.ts
@@ -0,0 +1,37 @@
+describe("waapi-svg-zero-duration", () => {
+ it("Restores SVG opacity with a zero-duration animation", () => {
+ cy.visit("?test=waapi-svg-zero-duration")
+ .get("#toggle")
+ .click()
+ .wait(400)
+ .get("#chip")
+ .then(([$chip]: any) => {
+ expect(getComputedStyle($chip).opacity).to.equal("0")
+ })
+ .get("#toggle")
+ .click()
+ .wait(50)
+ .get("#chip")
+ .then(([$chip]: any) => {
+ expect(getComputedStyle($chip).opacity).to.equal("1")
+ })
+ })
+
+ it("Restores SVG transform with a zero-duration animation", () => {
+ cy.visit("?test=waapi-svg-zero-duration")
+ .get("#toggle")
+ .click()
+ .wait(400)
+ .get("#transform-target")
+ .then(([$target]: any) => {
+ expect($target.style.transform).to.equal("translateX(50px)")
+ })
+ .get("#toggle")
+ .click()
+ .wait(50)
+ .get("#transform-target")
+ .then(([$target]: any) => {
+ expect($target.style.transform).to.equal("translateX(0px)")
+ })
+ })
+})
diff --git a/packages/framer-motion/cypress/integration/waapi-svg.ts b/packages/framer-motion/cypress/integration/waapi-svg.ts
index 4789a3a7be..114e80b23b 100644
--- a/packages/framer-motion/cypress/integration/waapi-svg.ts
+++ b/packages/framer-motion/cypress/integration/waapi-svg.ts
@@ -46,7 +46,7 @@ describe("waapi-svg", () => {
expect(translateX).to.be.greaterThan(3)
// Per-frame rendered values remain at the initial keyframe
- expect($circle.getAttribute("opacity")).to.equal("1")
+ expect($circle.style.opacity).to.equal("1")
expect($circle.style.transform).to.equal("translateX(0px)")
})
cy.get("#rect").then(([$rect]: any) => {
@@ -58,7 +58,7 @@ describe("waapi-svg", () => {
const [scaleX] = parseMatrix(computed.transform)
expect(scaleX).to.be.greaterThan(1.01)
- expect($rect.getAttribute("opacity")).to.equal("1")
+ expect($rect.style.opacity).to.equal("1")
expect($rect.style.transform).to.equal("scale(1)")
})
})
@@ -74,8 +74,9 @@ describe("waapi-svg", () => {
.getAnimations()
.flatMap((animation) =>
Object.keys(
- (animation.effect as KeyframeEffect).getKeyframes()[0] ??
- {}
+ (
+ animation.effect as KeyframeEffect
+ ).getKeyframes()[0] ?? {}
)
)
diff --git a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts
index 88652559e8..0a424c22a5 100644
--- a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts
+++ b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts
@@ -9,6 +9,35 @@ async function nextFrame() {
}
describe("svgEffect", () => {
+ it("renders SVG CSS properties as styles", async () => {
+ const element = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "circle"
+ )
+ const values = {
+ transform: motionValue("translateX(10px)"),
+ opacity: motionValue(0.5),
+ offsetDistance: motionValue("25%"),
+ offsetPath: motionValue('path("M 0 0 L 1 1")'),
+ offsetRotate: motionValue("auto"),
+ offsetAnchor: motionValue("center"),
+ }
+
+ svgEffect(element, values)
+ await nextFrame()
+
+ expect(element.style.transform).toBe("translateX(10px)")
+ expect(element.style.opacity).toBe("0.5")
+ expect(element.style.offsetDistance).toBe("25%")
+ expect(element.style.offsetPath).toBe('path("M 0 0 L 1 1")')
+ expect(element.style.offsetRotate).toBe("auto")
+ expect(element.style.offsetAnchor).toBe("center")
+
+ for (const key in values) {
+ expect(element.getAttribute(key)).toBeNull()
+ }
+ })
+
it("sets feMorphology radius as unitless number (issue #2779)", async () => {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
diff --git a/packages/motion-dom/src/render/svg/SVGVisualElement.ts b/packages/motion-dom/src/render/svg/SVGVisualElement.ts
index 2afe610c01..8d02e639e9 100644
--- a/packages/motion-dom/src/render/svg/SVGVisualElement.ts
+++ b/packages/motion-dom/src/render/svg/SVGVisualElement.ts
@@ -10,7 +10,7 @@ import { camelToDash } from "../dom/utils/camel-to-dash"
import type { ResolvedValues } from "../types"
import type { VisualElement, MotionStyle } from "../VisualElement"
import { SVGRenderState } from "./types"
-import { buildSVGAttrs } from "./utils/build-attrs"
+import { buildSVGAttrs, cssStyleProperties } from "./utils/build-attrs"
import { camelCaseAttributes } from "./utils/camel-case-attrs"
import { isSVGTag } from "./utils/is-svg-tag"
import { renderSVG } from "./utils/render"
@@ -36,6 +36,13 @@ export class SVGVisualElement extends DOMVisualElement<
const defaultType = getDefaultValueType(key)
return defaultType ? defaultType.default || 0 : 0
}
+
+ if (cssStyleProperties.includes(key)) {
+ const computedStyle = getComputedStyle(instance)
+ const value = computedStyle[key as keyof typeof computedStyle]
+ if (typeof value === "string" && value) return value.trim()
+ }
+
key = !camelCaseAttributes.has(key) ? camelToDash(key) : key
return instance.getAttribute(key)
}
diff --git a/packages/motion-dom/src/render/svg/__tests__/SVGVisualElement.test.ts b/packages/motion-dom/src/render/svg/__tests__/SVGVisualElement.test.ts
new file mode 100644
index 0000000000..f57cbfb4e0
--- /dev/null
+++ b/packages/motion-dom/src/render/svg/__tests__/SVGVisualElement.test.ts
@@ -0,0 +1,63 @@
+import { SVGVisualElement } from "../SVGVisualElement"
+
+const createVisualElement = () =>
+ new SVGVisualElement({
+ props: {},
+ presenceContext: null,
+ visualState: {
+ latestValues: {},
+ renderState: {
+ style: {},
+ vars: {},
+ transform: {},
+ transformOrigin: {},
+ attrs: {},
+ },
+ },
+ } as any)
+
+describe("SVGVisualElement", () => {
+ test.each([
+ ["transform", "translateX(10px)"],
+ ["opacity", "0.5"],
+ ["offsetDistance", "25%"],
+ ["offsetPath", 'path("M 0 0 L 1 1")'],
+ ["offsetRotate", "auto"],
+ ["offsetAnchor", "center"],
+ ])("reads %s from CSS style", (key, value) => {
+ const element = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "circle"
+ )
+ element.style[key as any] = value
+
+ expect(createVisualElement().readValueFromInstance(element, key)).toBe(
+ value
+ )
+ })
+
+ test.each([
+ ["transform", "translate(10 20)"],
+ ["opacity", "0.25"],
+ ["offsetDistance", "50%"],
+ ["offsetPath", "none"],
+ ["offsetRotate", "reverse"],
+ ["offsetAnchor", "auto"],
+ ])("falls back to the %s attribute", (key, value) => {
+ const element = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "circle"
+ )
+ element.setAttribute(
+ key.replace(
+ /[A-Z]/gu,
+ (character) => `-${character.toLowerCase()}`
+ ),
+ value
+ )
+
+ expect(createVisualElement().readValueFromInstance(element, key)).toBe(
+ value
+ )
+ })
+})
diff --git a/packages/motion-dom/src/render/svg/utils/build-attrs.ts b/packages/motion-dom/src/render/svg/utils/build-attrs.ts
index 95e4a48aab..0d6d940e6d 100644
--- a/packages/motion-dom/src/render/svg/utils/build-attrs.ts
+++ b/packages/motion-dom/src/render/svg/utils/build-attrs.ts
@@ -4,10 +4,9 @@ import { ResolvedValues } from "../../types"
import { SVGRenderState } from "../types"
import { buildSVGPath } from "./path"
-/**
- * CSS Motion Path properties that should remain as CSS styles on SVG elements.
- */
-const cssMotionPathProperties = [
+export const cssStyleProperties = [
+ "transform",
+ "opacity",
"offsetDistance",
"offsetPath",
"offsetRotate",
@@ -50,14 +49,13 @@ export function buildSVGAttrs(
state.style = {}
const { attrs, style } = state
- /**
- * However, we apply transforms as CSS transforms.
- * So if we detect a transform, transformOrigin we take it from attrs and copy it into style.
- */
- if (attrs.transform) {
- style.transform = attrs.transform
- delete attrs.transform
+ for (const key of cssStyleProperties) {
+ if (attrs[key] !== undefined) {
+ style[key] = attrs[key]
+ delete attrs[key]
+ }
}
+
if (style.transform || attrs.transformOrigin) {
style.transformOrigin = attrs.transformOrigin ?? "50% 50%"
delete attrs.transformOrigin
@@ -72,13 +70,6 @@ export function buildSVGAttrs(
delete attrs.transformBox
}
- for (const key of cssMotionPathProperties) {
- if (attrs[key] !== undefined) {
- style[key] = attrs[key]
- delete attrs[key]
- }
- }
-
// Render attrX/attrY/attrScale as attributes
if (attrX !== undefined) attrs.x = attrX
if (attrY !== undefined) attrs.y = attrY