diff --git a/dev/react/src/tests/svg-style-on-mount.tsx b/dev/react/src/tests/svg-style-on-mount.tsx new file mode 100644 index 0000000000..527545f0e0 --- /dev/null +++ b/dev/react/src/tests/svg-style-on-mount.tsx @@ -0,0 +1,51 @@ +"use client" + +import { motion, useMotionValue, useTransform } from "framer-motion" + +/** + * Test: SVG styles should apply correctly on mount when using useTransform. + * Reproduction for #2949: SVG transform-origin and styles not applying on mount. + * + * The bug: SVG elements with transforms derived from useTransform would have + * incorrect transformOrigin and transformBox on initial mount, causing a visible + * jump when the visual element takes over rendering. + */ +export function App() { + const x = useMotionValue(50) + + // Derived transform values via useTransform + const pathLength = useTransform(x, [0, 100], [0, 1]) + const opacity = useTransform(x, [0, 100], [0, 1]) + const fill = useTransform(x, [0, 100], ["#0000ff", "#ff0000"]) + + return ( + + {/* Path with useTransform-derived pathLength + opacity + CSS transform */} + + {/* Circle with useTransform-derived fill */} + + {/* Rect with static transform to test transformBox/transformOrigin */} + + + ) +} diff --git a/packages/framer-motion/cypress/integration/svg-style-on-mount.ts b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts new file mode 100644 index 0000000000..6b4629d302 --- /dev/null +++ b/packages/framer-motion/cypress/integration/svg-style-on-mount.ts @@ -0,0 +1,82 @@ +/** + * Tests for #2949: SVG styles not applying on mount. + * + * The bug: SVG elements using useTransform-derived values would have + * incorrect transformOrigin/transformBox on initial mount (before + * dimensions are measured), causing a visible "jump" when the visual + * element takes over. + * + * The fix: Always set transformBox to "fill-box" and transformOrigin + * to "50% 50%" on SVG elements with transforms, even before dimensions + * are measured. + */ +describe("SVG styles on mount (#2949)", () => { + it("Applies transform, transformBox, and transformOrigin on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // Transform should be applied immediately on mount + expect($path.style.transform).to.contain("translateX(10px)") + expect($path.style.transform).to.contain("translateY(10px)") + + // transformBox must be "fill-box" on initial mount + // (this was the core of the #2949 bug — it was missing) + expect($path.style.transformBox).to.equal("fill-box") + + // transformOrigin must be set to prevent jumping + expect($path.style.transformOrigin).to.equal("50% 50%") + }) + }) + + it("Applies useTransform-derived pathLength attributes on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // pathLength should be 0.5 (useTransform(50, [0,100], [0,1])) + // buildSVGPath normalizes the pathLength attribute to 1 + expect($path.getAttribute("pathLength")).to.equal("1") + + // stroke-dasharray should reflect pathLength=0.5 + expect($path.getAttribute("stroke-dasharray")).to.equal( + "0.5 1" + ) + + // stroke-dashoffset should be 0 (pathOffset defaults to 0) + const dashoffset = $path.getAttribute("stroke-dashoffset") + expect(parseFloat(dashoffset)).to.equal(0) + }) + }) + + it("Applies useTransform-derived opacity on SVG path on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#path") + .then(([$path]: any) => { + // opacity should be 0.5 (useTransform(50, [0,100], [0,1])) + const opacity = + $path.getAttribute("opacity") ?? + window.getComputedStyle($path).opacity + expect(parseFloat(opacity)).to.equal(0.5) + }) + }) + + it("Applies useTransform-derived fill on SVG circle on mount", () => { + cy.visit("?test=svg-style-on-mount") + .get("#circle") + .then(([$circle]: any) => { + // fill should be interpolated (not null/empty/default) + const fill = $circle.getAttribute("fill") + expect(fill).to.not.be.null + expect(fill).to.not.equal("") + }) + }) + + it("Applies transformBox and transformOrigin on SVG rect with static transform", () => { + cy.visit("?test=svg-style-on-mount") + .get("#rect") + .then(([$rect]: any) => { + expect($rect.style.transform).to.equal("rotate(45deg)") + expect($rect.style.transformBox).to.equal("fill-box") + expect($rect.style.transformOrigin).to.equal("50% 50%") + }) + }) +})